Receipt
import { useEffect, useRef, useState } from 'react';
type Stage = 'idle' | 'printing' | 'paid' | 'tear';
const css = `
.btn-receipt { position: relative; width: 176px; height: 184px; }
.btn-receipt__button {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 176px;
height: 48px;
border: none;
border-radius: 14px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font: inherit;
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: transform 0.15s;
}
.btn-receipt__button:active { transform: scale(0.97); }
.btn-receipt__button[aria-disabled='true'] { cursor: default; }
.btn-receipt__button:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
/* The printer slot along the bottom edge. */
.btn-receipt__button::after { content: ''; position: absolute; left: 30px; right: 30px; bottom: 6px; height: 3px; border-radius: 2px; background: light-dark(#fbfcfd, #121417); opacity: 0.3; }
.btn-receipt__check { width: 16px; height: 16px; }
.btn-receipt__mouth { position: absolute; z-index: 1; left: 34px; right: 34px; top: 42px; height: 142px; overflow: hidden; }
.btn-receipt__paper {
margin: 0;
padding: 12px 10px 16px;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-top: 0;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#50535b, #c6c8cd);
font: 10px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
clip-path: inset(0 0 100% 0);
-webkit-mask: linear-gradient(#000 0 0) top / 100% calc(100% - 5px) no-repeat, conic-gradient(from -45deg at 50% 100%, #000 90deg, transparent 0) bottom / 8px 5px repeat-x;
mask: linear-gradient(#000 0 0) top / 100% calc(100% - 5px) no-repeat, conic-gradient(from -45deg at 50% 100%, #000 90deg, transparent 0) bottom / 8px 5px repeat-x;
}
.btn-receipt__row { display: flex; justify-content: space-between; }
.btn-receipt__row--total { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
.btn-receipt__rule { border-top: 1px dashed light-dark(#cbced3, #393c41); margin: 4px 0; }
/* The paper comes out in jerks, a line at a time, the way a receipt printer advances it. */
.btn-receipt--printing .btn-receipt__paper { animation: btn-receipt-feed 1.3s steps(7, end) forwards; }
.btn-receipt--paid .btn-receipt__paper { clip-path: inset(0); }
.btn-receipt--tear .btn-receipt__paper { clip-path: inset(0); animation: btn-receipt-tear 0.6s cubic-bezier(0.5, 0, 0.8, 0.6) forwards; }
@keyframes btn-receipt-feed {
from { clip-path: inset(0 0 100% 0); }
to { clip-path: inset(0); }
}
@keyframes btn-receipt-tear {
from { transform: none; opacity: 1; }
to { transform: translate(12px, 70px) rotate(9deg); opacity: 0; }
}
.btn-receipt__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.btn-receipt--printing .btn-receipt__paper { animation: none; clip-path: inset(0); }
.btn-receipt--tear .btn-receipt__paper { animation: btn-receipt-fade 0.3s forwards; }
@keyframes btn-receipt-fade {
to { opacity: 0; }
}
}
`;
export function ReceiptButton() {
const [stage, setStage] = useState<Stage>('idle');
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const pay = () => {
if (stage !== 'idle') return;
setStage('printing');
const at = (ms: number, s: Stage) => timers.current.push(window.setTimeout(() => setStage(s), ms));
at(1400, 'paid');
at(2600, 'tear');
at(3250, 'idle');
};
return (
<>
<style>
{css}
</style>
<div className={`btn-receipt btn-receipt--${stage}`}>
<button type="button" className="btn-receipt__button" aria-disabled={stage !== 'idle'} onClick={pay}>
{stage === 'idle' ? (
'Pay $24.00'
) : stage === 'printing' ? (
'Printing…'
) : (
<>
<svg
className="btn-receipt__check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
Paid
</>
)}
</button>
<div className="btn-receipt__mouth" aria-hidden="true">
<div className="btn-receipt__paper">
<div className="btn-receipt__row">
<span>ORDER</span>
<span>#1042</span>
</div>
<div className="btn-receipt__rule" />
<div className="btn-receipt__row">
<span>Tote bag</span>
<span>20.00</span>
</div>
<div className="btn-receipt__row">
<span>Shipping</span>
<span>4.00</span>
</div>
<div className="btn-receipt__rule" />
<div className="btn-receipt__row btn-receipt__row--total">
<span>TOTAL</span>
<span>$24.00</span>
</div>
<div className="btn-receipt__row">
<span>PAID</span>
<span>CARD 4242</span>
</div>
</div>
</div>
<span className="btn-receipt__status" role="status">
{stage === 'paid' ? 'Paid $24.00. Receipt printed.' : ''}
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnReceipt(el) on the mounted element instead.
<div class="btn-receipt btn-receipt--idle">
<button type="button" class="btn-receipt__button" aria-disabled="false">Pay $24.00</button>
<div class="btn-receipt__mouth" aria-hidden="true">
<div class="btn-receipt__paper">
<div class="btn-receipt__row">
<span>ORDER</span>
<span>#1042</span>
</div>
<div class="btn-receipt__rule"></div>
<div class="btn-receipt__row">
<span>Tote bag</span>
<span>20.00</span>
</div>
<div class="btn-receipt__row">
<span>Shipping</span>
<span>4.00</span>
</div>
<div class="btn-receipt__rule"></div>
<div class="btn-receipt__row btn-receipt__row--total">
<span>TOTAL</span>
<span>$24.00</span>
</div>
<div class="btn-receipt__row">
<span>PAID</span>
<span>CARD 4242</span>
</div>
</div>
</div>
<span class="btn-receipt__status" role="status"></span>
</div>.btn-receipt { position: relative; width: 176px; height: 184px; }
.btn-receipt__button {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 176px;
height: 48px;
border: none;
border-radius: 14px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font: inherit;
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: transform 0.15s;
}
.btn-receipt__button:active { transform: scale(0.97); }
.btn-receipt__button[aria-disabled='true'] { cursor: default; }
.btn-receipt__button:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
/* The printer slot along the bottom edge. */
.btn-receipt__button::after { content: ''; position: absolute; left: 30px; right: 30px; bottom: 6px; height: 3px; border-radius: 2px; background: light-dark(#fbfcfd, #121417); opacity: 0.3; }
.btn-receipt__check { width: 16px; height: 16px; }
.btn-receipt__mouth { position: absolute; z-index: 1; left: 34px; right: 34px; top: 42px; height: 142px; overflow: hidden; }
.btn-receipt__paper {
margin: 0;
padding: 12px 10px 16px;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-top: 0;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#50535b, #c6c8cd);
font: 10px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
clip-path: inset(0 0 100% 0);
-webkit-mask: linear-gradient(#000 0 0) top / 100% calc(100% - 5px) no-repeat, conic-gradient(from -45deg at 50% 100%, #000 90deg, transparent 0) bottom / 8px 5px repeat-x;
mask: linear-gradient(#000 0 0) top / 100% calc(100% - 5px) no-repeat, conic-gradient(from -45deg at 50% 100%, #000 90deg, transparent 0) bottom / 8px 5px repeat-x;
}
.btn-receipt__row { display: flex; justify-content: space-between; }
.btn-receipt__row--total { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
.btn-receipt__rule { border-top: 1px dashed light-dark(#cbced3, #393c41); margin: 4px 0; }
/* The paper comes out in jerks, a line at a time, the way a receipt printer advances it. */
.btn-receipt--printing .btn-receipt__paper { animation: btn-receipt-feed 1.3s steps(7, end) forwards; }
.btn-receipt--paid .btn-receipt__paper { clip-path: inset(0); }
.btn-receipt--tear .btn-receipt__paper { clip-path: inset(0); animation: btn-receipt-tear 0.6s cubic-bezier(0.5, 0, 0.8, 0.6) forwards; }
@keyframes btn-receipt-feed {
from { clip-path: inset(0 0 100% 0); }
to { clip-path: inset(0); }
}
@keyframes btn-receipt-tear {
from { transform: none; opacity: 1; }
to { transform: translate(12px, 70px) rotate(9deg); opacity: 0; }
}
.btn-receipt__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.btn-receipt--printing .btn-receipt__paper { animation: none; clip-path: inset(0); }
.btn-receipt--tear .btn-receipt__paper { animation: btn-receipt-fade 0.3s forwards; }
@keyframes btn-receipt-fade {
to { opacity: 0; }
}
}function initBtnReceipt(root) {
var button = root.querySelector('.btn-receipt__button');
var status = root.querySelector('.btn-receipt__status');
var CHECK =
'<svg class="btn-receipt__check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M5 12.5l4.5 4.5L19 7.5"></path></svg>';
var stage = 'idle';
var timers = [];
// idle, printing, paid, tear: each is a class on the root; the CSS animates the paper for each.
// Under prefers-reduced-motion the CSS shows the receipt at once and fades it instead.
function set(next) {
root.classList.remove('btn-receipt--' + stage);
stage = next;
root.classList.add('btn-receipt--' + stage);
button.setAttribute('aria-disabled', String(stage !== 'idle'));
button.innerHTML = stage === 'idle' ? 'Pay $24.00' : stage === 'printing' ? 'Printing…' : CHECK + 'Paid';
status.textContent = stage === 'paid' ? 'Paid $24.00. Receipt printed.' : '';
}
function pay() {
if (stage !== 'idle') return;
set('printing');
timers.push(setTimeout(set, 1400, 'paid'), setTimeout(set, 2600, 'tear'), setTimeout(set, 3250, 'idle'));
}
button.addEventListener('click', pay);
return function () {
timers.forEach(clearTimeout);
button.removeEventListener('click', pay);
};
}
document.querySelectorAll('.btn-receipt').forEach(initBtnReceipt);Goal: Create a "Receipt" button.
Visual behavior: A pay button with a printer slot along its bottom edge: click it and a receipt prints out under it a line at a time, it reads Paid, then the receipt tears off and falls away.
Technique:
- A small state machine on timers (idle, printing, paid, tear) sets a class on the root and the button label. The receipt sits behind the button in a clipped mouth; printing reveals it with a clip-path inset animated in steps, so it comes out in jerks like a receipt printer.
- Its torn bottom edge is a mask: a solid block above a repeating conic gradient that cuts a zigzag. Tearing translates and rotates it away as it fades.
Accessibility: A real button with aria-disabled while it runs; a visually hidden role="status" announces the payment and the receipt. Under prefers-reduced-motion the receipt appears at once and fades instead of falling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Receipt" button.
Visual behavior: A pay button with a printer slot along its bottom edge: click it and a receipt prints out under it a line at a time, it reads Paid, then the receipt tears off and falls away.
Technique:
- A small state machine on timers (idle, printing, paid, tear) sets a class on the root and the button label. The receipt sits behind the button in a clipped mouth; printing reveals it with a clip-path inset animated in steps, so it comes out in jerks like a receipt printer.
- Its torn bottom edge is a mask: a solid block above a repeating conic gradient that cuts a zigzag. Tearing translates and rotates it away as it fades.
Accessibility: A real button with aria-disabled while it runs; a visually hidden role="status" announces the payment and the receipt. Under prefers-reduced-motion the receipt appears at once and fades instead of falling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A pay button with a printer slot along its bottom edge: click it and a receipt prints out under it a line at a time, it reads Paid, then the receipt tears off and falls away.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe
- Techniques
- keyframes, clip path, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real button with aria-disabled while it runs; a visually hidden role="status" announces the payment and the receipt. Under prefers-reduced-motion the receipt appears at once and fades instead of falling.