Receipt Printer (Pull to Tear)
A receipt that prints out of a metal slot after payment, line by line with a printer buzz, then tears off when you pull it down. Mobile and desktop.
Preview and code
<!--
MIT License
Copyright (c) 2026 OwnlyPlans
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-->
<div class="rcpt" id="receipt"></div>
/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.rcpt{--rcpt-w:290px;--rcpt-ink:#16181d;--rcpt-muted:#5f6470;--rcpt-paper:#fdfdfc;--rcpt-accent:#16181d;
position:relative;width:min(var(--rcpt-w),100%);display:grid;justify-items:center;font:13px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--rcpt-ink);user-select:none;-webkit-user-select:none}
/* the printer slot: a brushed metal bar with a dark slit */
.rcpt-slot{position:relative;z-index:3;width:100%;height:38px;border-radius:10px;background:linear-gradient(180deg,#f4f5f7 0%,#d7dade 45%,#bfc3c9 100%);
box-shadow:inset 0 1px 0 #fff,inset 0 -2px 3px rgba(0,0,0,.18),0 10px 18px -10px rgba(0,0,0,.45)}
.rcpt-slot i{position:absolute;left:9%;right:9%;top:21px;height:5px;border-radius:3px;background:linear-gradient(180deg,#202227,#3a3d44);box-shadow:inset 0 1px 2px rgba(0,0,0,.6)}
.rcpt-slot b{position:absolute;right:12px;top:8px;width:6px;height:6px;border-radius:50%;background:#9aa0a8;transition:background .2s,box-shadow .2s}
.rcpt.is-printing .rcpt-slot b{background:#2fd16b;box-shadow:0 0 8px #2fd16b}
/* paper comes out from under the slit; only the top is clipped so a torn receipt can fall freely */
.rcpt-feed{position:relative;z-index:2;width:84%;margin-top:-14px;clip-path:inset(0 -60px -2000px -60px);filter:drop-shadow(0 10px 12px rgba(20,24,32,.18))}
.rcpt-paper{position:relative;padding:22px 18px 22px;background:linear-gradient(180deg,#f1f2f2 0,var(--rcpt-paper) 26px,var(--rcpt-paper) 80%,#f4f4f2);touch-action:none;cursor:grab;will-change:transform;
-webkit-mask:linear-gradient(#000 0 0) 0 0/100% calc(100% - 7px) no-repeat,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x;
mask:linear-gradient(#000 0 0) 0 0/100% calc(100% - 7px) no-repeat,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x}
.rcpt-paper:active{cursor:grabbing}
.rcpt.is-torn .rcpt-paper,.rcpt-paper.is-torn{
-webkit-mask:linear-gradient(#000 0 0) 0 7px/100% calc(100% - 14px) no-repeat,conic-gradient(from 135deg at top,#0000,#000 1deg 89deg,#0000 90deg) 0 0/14px 7px repeat-x,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x;
mask:linear-gradient(#000 0 0) 0 7px/100% calc(100% - 14px) no-repeat,conic-gradient(from 135deg at top,#0000,#000 1deg 89deg,#0000 90deg) 0 0/14px 7px repeat-x,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x}
.rcpt-title{margin:0;text-align:center;font-size:19px;font-weight:800;letter-spacing:.04em}
.rcpt-meta{display:flex;justify-content:space-between;margin:6px 0 8px;color:var(--rcpt-muted);font-size:10.5px;font-variant-numeric:tabular-nums}
.rcpt-rule{height:5px;border-top:1px solid var(--rcpt-ink);border-bottom:1px solid var(--rcpt-ink);opacity:.75;margin-bottom:10px}
.rcpt-table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
.rcpt-table th{padding:0 0 6px;font-size:11.5px;font-weight:700;text-align:left}
.rcpt-table th:last-child,.rcpt-table td:last-child{text-align:right;white-space:nowrap;padding-left:10px}
.rcpt-table th:last-child{font-weight:500}
.rcpt-table td{padding:1.5px 0;vertical-align:top}
.rcpt-table td small{color:var(--rcpt-muted)}
.rcpt-sum{display:grid;grid-template-columns:1fr auto;gap:2px 10px;margin:10px 0 0;padding-top:8px;border-top:1px dashed rgba(22,24,29,.35);font-variant-numeric:tabular-nums}
.rcpt-sum dt,.rcpt-sum dd{margin:0}.rcpt-sum dd{text-align:right}
.rcpt-sum .tot{font-weight:800;font-size:14px;margin-top:3px}
.rcpt-bar{display:block;width:100%;height:30px;margin:12px 0 3px}
.rcpt-bar rect{fill:var(--rcpt-ink)}
.rcpt-id{margin:0;text-align:center;font-size:10.5px;letter-spacing:.08em;font-variant-numeric:tabular-nums}
.rcpt-note{margin:6px 0 0;text-align:center;color:var(--rcpt-muted);font-size:11px}
.rcpt-stub{position:absolute;left:0;right:0;top:0;height:10px;background:var(--rcpt-paper);opacity:0;pointer-events:none;
-webkit-mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x,linear-gradient(#000 0 0) 0 0/100% 3px no-repeat;
mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x,linear-gradient(#000 0 0) 0 0/100% 3px no-repeat}
.rcpt.is-torn .rcpt-stub{opacity:1}
.rcpt-actions{position:relative;z-index:4;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 12px;margin-top:12px;min-height:34px}
.rcpt-btn{height:34px;padding:0 16px;border:1px solid rgba(22,24,29,.18);border-radius:999px;background:var(--rcpt-accent);color:#fff;font:600 12.5px system-ui,sans-serif;cursor:pointer;transition:transform .15s,opacity .2s}
.rcpt-btn:active{transform:scale(.96)}.rcpt-btn:disabled{opacity:.45;cursor:default}
.rcpt-btn:focus-visible{outline:2px solid var(--rcpt-accent);outline-offset:3px}
.rcpt-hint{color:var(--rcpt-muted);font-size:11.5px}
.rcpt-hint.nudge{animation:rcpt-nudge .5s}
@keyframes rcpt-nudge{25%{transform:translateY(-3px)}60%{transform:translateY(2px)}}
:root[data-stage=dark] .rcpt,.rcpt.rcpt--dark{--rcpt-muted:#5f6470}
:root[data-stage=dark] .rcpt-hint{color:#aab0bd}
:root[data-stage=dark] .rcpt-btn{background:#f4f5f7;color:#16181d}
.rcpt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-height:560px){.rcpt{zoom:.82}}
@media (max-height:470px){.rcpt{zoom:.74}}
@media (max-height:380px){.rcpt{zoom:.6}}
@media (max-height:320px){.rcpt{zoom:.54}}
@media (prefers-reduced-motion:reduce){.rcpt-hint.nudge{animation:none}}
/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/* Receipt Printer: builds the receipt inside `root`, prints it, and lets people pull it to tear it off. Returns a cleanup function. */
function mountReceipt(root, o = {}) {
const opt = {
title: 'RECEIPT', currency: 'USD', locale: 'en-US', taxRate: 0.08, note: 'Thank you for your purchase!', autoPrint: true, sound: true,
orderId: String(Date.now()).slice(-10) + String(Math.floor(Math.random() * 900 + 100)),
items: [
{ name: 'Camera', price: 2500 }, { name: 'Lens Upgrade', price: 1250 }, { name: 'Studio Lighting', price: 800, qty: 2 },
{ name: 'Microphone', price: 300 }, { name: 'Editing Software', price: 60, per: 'mo' },
],
...o,
};
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
root.classList.add('rcpt');
root.innerHTML =
'<div class="rcpt-slot" aria-hidden="true"><i></i><b></b></div>' +
'<div class="rcpt-feed"><div class="rcpt-stub" aria-hidden="true"></div><div class="rcpt-paper" role="document" aria-label="Receipt">' +
'<h3 class="rcpt-title"></h3><div class="rcpt-meta"><span class="rcpt-date"></span><span class="rcpt-no"></span></div><div class="rcpt-rule" aria-hidden="true"></div>' +
'<table class="rcpt-table"><thead><tr><th scope="col">Description</th><th scope="col">Price</th></tr></thead><tbody></tbody></table>' +
'<dl class="rcpt-sum"><dt>Subtotal</dt><dd class="rcpt-sub"></dd><dt class="rcpt-taxl"></dt><dd class="rcpt-tax"></dd><dt class="tot">Total</dt><dd class="tot rcpt-total"></dd></dl>' +
'<svg class="rcpt-bar" viewBox="0 0 200 30" preserveAspectRatio="none" aria-hidden="true"></svg><p class="rcpt-id"></p><p class="rcpt-note"></p></div></div>' +
'<div class="rcpt-actions"><button type="button" class="rcpt-btn">Print receipt</button><span class="rcpt-hint" aria-live="polite"></span></div>';
const $ = (s) => root.querySelector(s);
const feed = $('.rcpt-feed'), paper = $('.rcpt-paper'), btn = $('.rcpt-btn'), hint = $('.rcpt-hint');
let state = 'idle', raf = 0, alive = true;
let drag = null;
/* ---------- content ---------- */
const money = new Intl.NumberFormat(opt.locale, { style: 'currency', currency: opt.currency });
function fill() {
$('.rcpt-title').textContent = opt.title;
$('.rcpt-date').textContent = new Date().toLocaleString(opt.locale, { dateStyle: 'medium', timeStyle: 'short' });
$('.rcpt-no').textContent = 'No. ' + opt.orderId.slice(-6);
const body = $('.rcpt-table tbody'); body.replaceChildren();
let sub = 0;
opt.items.forEach((it) => {
const qty = it.qty || 1, line = it.price * qty; sub += line;
const tr = document.createElement('tr'), a = document.createElement('td'), b = document.createElement('td');
a.textContent = it.name;
if (qty > 1) { const s = document.createElement('small'); s.textContent = ' ×' + qty; a.append(s); }
b.textContent = money.format(line) + (it.per ? '/' + it.per : '');
tr.append(a, b); body.append(tr);
});
const tax = Math.round(sub * opt.taxRate * 100) / 100;
$('.rcpt-sub').textContent = money.format(sub);
$('.rcpt-taxl').textContent = 'Tax (' + Math.round(opt.taxRate * 1000) / 10 + '%)';
$('.rcpt-tax').textContent = money.format(tax);
$('.rcpt-total').textContent = money.format(sub + tax);
$('.rcpt-id').textContent = '#' + opt.orderId;
$('.rcpt-note').textContent = opt.note;
/* decorative barcode drawn from the order number */
let x = 2, svg = '', seed = 7;
for (let i = 0; x < 196 && i < 400; i++) {
seed = (seed * 31 + opt.orderId.charCodeAt(i % opt.orderId.length)) % 997;
const w = 0.8 + (seed % 4) * 0.7, gap = 0.8 + ((seed >> 2) % 3) * 0.7;
if (x + w > 198) break;
svg += '<rect x="' + x.toFixed(2) + '" y="0" width="' + w.toFixed(2) + '" height="' + (seed % 5 === 0 ? 30 : 26) + '"/>'; x += w + gap;
}
$('.rcpt-bar').innerHTML = svg;
}
/* ---------- sound (made in code, plays only after a click) ---------- */
let ac = null;
function noise(len) {
const a = ac, b = a.createBuffer(1, Math.floor(a.sampleRate * len), a.sampleRate), d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
}
function sound(kind, dur = 0.1) {
if (!opt.sound) return;
try {
const AC = window.AudioContext || (window).webkitAudioContext;
if (!AC) return;
ac = ac || new AC();
const a = ac, t = a.currentTime, src = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain();
src.connect(f); f.connect(g); g.connect(a.destination);
if (kind === 'feed') {
/* thermal printer: a short buzzing burst per line */
src.buffer = noise(dur); f.type = 'bandpass'; f.frequency.value = 2300; f.Q.value = 3;
const lfo = a.createOscillator(), lg = a.createGain(); lfo.frequency.value = 95; lg.gain.value = 0.05; lfo.connect(lg); lg.connect(g.gain);
g.gain.setValueAtTime(0.06, t); g.gain.setValueAtTime(0.0001, t + dur); lfo.start(t); lfo.stop(t + dur); src.start(t); src.stop(t + dur);
} else {
/* paper rip: crackly noise sweeping down */
src.buffer = noise(0.32); f.type = 'bandpass'; f.Q.value = 0.9; f.frequency.setValueAtTime(4200, t); f.frequency.exponentialRampToValueAtTime(700, t + 0.3);
for (let i = 0; i < 12; i++) g.gain.setValueAtTime(i % 2 ? 0.08 : 0.32 + Math.random() * 0.2, t + i * 0.025);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.32); src.start(t); src.stop(t + 0.33);
}
} catch (e) { /* no audio: stay silent */ }
}
/* ---------- printing: the paper feeds out in small steps, like a real printer ---------- */
function setY(y, dx = 0, rot = 0) { paper.style.transform = 'translate(' + dx + 'px,' + y + 'px) rotate(' + rot + 'deg)'; }
function print() {
if (!alive) return;
cancelAnimationFrame(raf);
paper.getAnimations().forEach((a) => a.cancel());
root.classList.remove('is-torn'); paper.classList.remove('is-torn'); paper.style.opacity = '1';
fill();
const H = paper.offsetHeight;
feed.style.height = H + 'px';
state = 'printing'; root.classList.add('is-printing'); btn.disabled = true; btn.textContent = 'Printing…'; hint.textContent = '';
if (reduce) { setY(0); done(); return; }
const step = 18, steps = Math.ceil(H / step), stepMs = Math.max(55, Math.min(110, 2400 / steps));
let i = 0, t0 = -1;
setY(-H);
const tick = (now) => {
if (!alive) return;
if (t0 < 0) t0 = now;
const k = (now - t0) / stepMs, n = Math.floor(k), frac = Math.min(1, (k - n) / 0.55);
if (n > i) { i = n; if (i <= steps) sound('feed', stepMs / 1000 * 0.5); }
const shown = Math.min(H, (n + frac) * step);
setY(-H + shown);
if (shown >= H) { done(); return; }
raf = requestAnimationFrame(tick);
};
sound('feed', stepMs / 1000 * 0.5);
raf = requestAnimationFrame(tick);
}
function done() {
state = 'printed'; root.classList.remove('is-printing'); btn.disabled = false; btn.textContent = 'Tear off';
hint.textContent = 'Hold and pull the receipt down to tear it off';
root.dispatchEvent(new CustomEvent('receipt:printed', { bubbles: true, detail: { orderId: opt.orderId } }));
}
/* ---------- tearing ---------- */
function tear(dir) {
if (state !== 'printed') return;
state = 'torn'; sound('rip');
root.classList.add('is-torn'); paper.classList.add('is-torn');
const from = paper.style.transform || 'translate(0px,0px)';
const fall = paper.animate(
reduce ? [{ opacity: 1 }, { opacity: 0 }] : [{ transform: from, opacity: 1 }, { transform: 'translate(' + (dir * 46) + 'px,' + (feed.offsetHeight + 80) + 'px) rotate(' + (dir * 9 + 3) + 'deg)', opacity: 0 }],
{ duration: reduce ? 200 : 750, easing: 'cubic-bezier(.45,0,.8,.35)', fill: 'forwards' });
btn.textContent = 'Print again'; hint.textContent = 'Receipt torn off';
fall.onfinish = () => { if (alive) { paper.style.opacity = '0'; feed.style.height = '12px'; } };
root.dispatchEvent(new CustomEvent('receipt:torn', { bubbles: true, detail: { orderId: opt.orderId } }));
}
function nudge(text) { hint.textContent = text; hint.classList.remove('nudge'); void hint.offsetWidth; hint.classList.add('nudge'); }
function onDown(e) {
if (state !== 'printed' || e.button > 0) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false };
try { paper.setPointerCapture(e.pointerId); } catch (err) { /* synthetic or finished pointer: dragging still works */ }
paper.style.transition = 'none';
}
function onMove(e) {
if (!drag || e.pointerId !== drag.id) return;
const dx = e.clientX - drag.x, dy = Math.max(0, e.clientY - drag.y);
if (Math.hypot(dx, dy) > 4) drag.moved = true;
setY(dy * 0.35, dx * 0.12, dx * 0.025);
if (dy > 70 || Math.abs(dx) > 110) { const d = dx >= 0 ? 1 : -1; drag = null; tear(d); }
}
function onUp(e) {
if (!drag || e.pointerId !== drag.id) return;
const moved = drag.moved; drag = null;
paper.style.transition = 'transform .35s cubic-bezier(.3,1.5,.5,1)'; setY(0);
setTimeout(() => { paper.style.transition = ''; }, 360);
nudge(moved ? 'Pull a little further to tear it' : 'Hold and pull down to tear it off');
}
const onBtn = () => { if (state === 'printed') tear(1); else if (state !== 'printing') print(); };
paper.addEventListener('pointerdown', onDown); paper.addEventListener('pointermove', onMove);
paper.addEventListener('pointerup', onUp); paper.addEventListener('pointercancel', onUp);
btn.addEventListener('click', onBtn);
fill(); feed.style.height = '12px'; setY(-paper.offsetHeight);
if (opt.autoPrint) print();
return () => { alive = false; cancelAnimationFrame(raf); root.innerHTML = ''; };
}
const receiptRoot = document.getElementById('receipt');
mountReceipt(receiptRoot);
/* Listen for receipt:printed and receipt:torn to react in your app. */
/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
const css = `.rcpt{--rcpt-w:290px;--rcpt-ink:#16181d;--rcpt-muted:#5f6470;--rcpt-paper:#fdfdfc;--rcpt-accent:#16181d;
position:relative;width:min(var(--rcpt-w),100%);display:grid;justify-items:center;font:13px/1.35 system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--rcpt-ink);user-select:none;-webkit-user-select:none}
/* the printer slot: a brushed metal bar with a dark slit */
.rcpt-slot{position:relative;z-index:3;width:100%;height:38px;border-radius:10px;background:linear-gradient(180deg,#f4f5f7 0%,#d7dade 45%,#bfc3c9 100%);
box-shadow:inset 0 1px 0 #fff,inset 0 -2px 3px rgba(0,0,0,.18),0 10px 18px -10px rgba(0,0,0,.45)}
.rcpt-slot i{position:absolute;left:9%;right:9%;top:21px;height:5px;border-radius:3px;background:linear-gradient(180deg,#202227,#3a3d44);box-shadow:inset 0 1px 2px rgba(0,0,0,.6)}
.rcpt-slot b{position:absolute;right:12px;top:8px;width:6px;height:6px;border-radius:50%;background:#9aa0a8;transition:background .2s,box-shadow .2s}
.rcpt.is-printing .rcpt-slot b{background:#2fd16b;box-shadow:0 0 8px #2fd16b}
/* paper comes out from under the slit; only the top is clipped so a torn receipt can fall freely */
.rcpt-feed{position:relative;z-index:2;width:84%;margin-top:-14px;clip-path:inset(0 -60px -2000px -60px);filter:drop-shadow(0 10px 12px rgba(20,24,32,.18))}
.rcpt-paper{position:relative;padding:22px 18px 22px;background:linear-gradient(180deg,#f1f2f2 0,var(--rcpt-paper) 26px,var(--rcpt-paper) 80%,#f4f4f2);touch-action:none;cursor:grab;will-change:transform;
-webkit-mask:linear-gradient(#000 0 0) 0 0/100% calc(100% - 7px) no-repeat,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x;
mask:linear-gradient(#000 0 0) 0 0/100% calc(100% - 7px) no-repeat,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x}
.rcpt-paper:active{cursor:grabbing}
.rcpt.is-torn .rcpt-paper,.rcpt-paper.is-torn{
-webkit-mask:linear-gradient(#000 0 0) 0 7px/100% calc(100% - 14px) no-repeat,conic-gradient(from 135deg at top,#0000,#000 1deg 89deg,#0000 90deg) 0 0/14px 7px repeat-x,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x;
mask:linear-gradient(#000 0 0) 0 7px/100% calc(100% - 14px) no-repeat,conic-gradient(from 135deg at top,#0000,#000 1deg 89deg,#0000 90deg) 0 0/14px 7px repeat-x,conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x}
.rcpt-title{margin:0;text-align:center;font-size:19px;font-weight:800;letter-spacing:.04em}
.rcpt-meta{display:flex;justify-content:space-between;margin:6px 0 8px;color:var(--rcpt-muted);font-size:10.5px;font-variant-numeric:tabular-nums}
.rcpt-rule{height:5px;border-top:1px solid var(--rcpt-ink);border-bottom:1px solid var(--rcpt-ink);opacity:.75;margin-bottom:10px}
.rcpt-table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
.rcpt-table th{padding:0 0 6px;font-size:11.5px;font-weight:700;text-align:left}
.rcpt-table th:last-child,.rcpt-table td:last-child{text-align:right;white-space:nowrap;padding-left:10px}
.rcpt-table th:last-child{font-weight:500}
.rcpt-table td{padding:1.5px 0;vertical-align:top}
.rcpt-table td small{color:var(--rcpt-muted)}
.rcpt-sum{display:grid;grid-template-columns:1fr auto;gap:2px 10px;margin:10px 0 0;padding-top:8px;border-top:1px dashed rgba(22,24,29,.35);font-variant-numeric:tabular-nums}
.rcpt-sum dt,.rcpt-sum dd{margin:0}.rcpt-sum dd{text-align:right}
.rcpt-sum .tot{font-weight:800;font-size:14px;margin-top:3px}
.rcpt-bar{display:block;width:100%;height:30px;margin:12px 0 3px}
.rcpt-bar rect{fill:var(--rcpt-ink)}
.rcpt-id{margin:0;text-align:center;font-size:10.5px;letter-spacing:.08em;font-variant-numeric:tabular-nums}
.rcpt-note{margin:6px 0 0;text-align:center;color:var(--rcpt-muted);font-size:11px}
.rcpt-stub{position:absolute;left:0;right:0;top:0;height:10px;background:var(--rcpt-paper);opacity:0;pointer-events:none;
-webkit-mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x,linear-gradient(#000 0 0) 0 0/100% 3px no-repeat;
mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 0 100%/14px 7px repeat-x,linear-gradient(#000 0 0) 0 0/100% 3px no-repeat}
.rcpt.is-torn .rcpt-stub{opacity:1}
.rcpt-actions{position:relative;z-index:4;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 12px;margin-top:12px;min-height:34px}
.rcpt-btn{height:34px;padding:0 16px;border:1px solid rgba(22,24,29,.18);border-radius:999px;background:var(--rcpt-accent);color:#fff;font:600 12.5px system-ui,sans-serif;cursor:pointer;transition:transform .15s,opacity .2s}
.rcpt-btn:active{transform:scale(.96)}.rcpt-btn:disabled{opacity:.45;cursor:default}
.rcpt-btn:focus-visible{outline:2px solid var(--rcpt-accent);outline-offset:3px}
.rcpt-hint{color:var(--rcpt-muted);font-size:11.5px}
.rcpt-hint.nudge{animation:rcpt-nudge .5s}
@keyframes rcpt-nudge{25%{transform:translateY(-3px)}60%{transform:translateY(2px)}}
:root[data-stage=dark] .rcpt,.rcpt.rcpt--dark{--rcpt-muted:#5f6470}
:root[data-stage=dark] .rcpt-hint{color:#aab0bd}
:root[data-stage=dark] .rcpt-btn{background:#f4f5f7;color:#16181d}
.rcpt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-height:560px){.rcpt{zoom:.82}}
@media (max-height:470px){.rcpt{zoom:.74}}
@media (max-height:380px){.rcpt{zoom:.6}}
@media (max-height:320px){.rcpt{zoom:.54}}
@media (prefers-reduced-motion:reduce){.rcpt-hint.nudge{animation:none}}`;
export type ReceiptItem = { name: string; price: number; qty?: number; /** e.g. "mo" shows "/mo" */ per?: string };
export type ReceiptOptions = {
title?: string;
items?: ReceiptItem[];
/** ISO currency code, e.g. "USD", "INR", "EUR". */
currency?: string;
locale?: string;
/** 0.08 = 8% */
taxRate?: number;
orderId?: string;
note?: string;
autoPrint?: boolean;
sound?: boolean;
};
/* Receipt Printer: builds the receipt inside `root`, prints it, and lets people pull it to tear it off. Returns a cleanup function. */
export function mountReceipt(root: HTMLElement, o: ReceiptOptions = {}): () => void {
const opt = {
title: 'RECEIPT', currency: 'USD', locale: 'en-US', taxRate: 0.08, note: 'Thank you for your purchase!', autoPrint: true, sound: true,
orderId: String(Date.now()).slice(-10) + String(Math.floor(Math.random() * 900 + 100)),
items: [
{ name: 'Camera', price: 2500 }, { name: 'Lens Upgrade', price: 1250 }, { name: 'Studio Lighting', price: 800, qty: 2 },
{ name: 'Microphone', price: 300 }, { name: 'Editing Software', price: 60, per: 'mo' },
],
...o,
};
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
root.classList.add('rcpt');
root.innerHTML =
'<div class="rcpt-slot" aria-hidden="true"><i></i><b></b></div>' +
'<div class="rcpt-feed"><div class="rcpt-stub" aria-hidden="true"></div><div class="rcpt-paper" role="document" aria-label="Receipt">' +
'<h3 class="rcpt-title"></h3><div class="rcpt-meta"><span class="rcpt-date"></span><span class="rcpt-no"></span></div><div class="rcpt-rule" aria-hidden="true"></div>' +
'<table class="rcpt-table"><thead><tr><th scope="col">Description</th><th scope="col">Price</th></tr></thead><tbody></tbody></table>' +
'<dl class="rcpt-sum"><dt>Subtotal</dt><dd class="rcpt-sub"></dd><dt class="rcpt-taxl"></dt><dd class="rcpt-tax"></dd><dt class="tot">Total</dt><dd class="tot rcpt-total"></dd></dl>' +
'<svg class="rcpt-bar" viewBox="0 0 200 30" preserveAspectRatio="none" aria-hidden="true"></svg><p class="rcpt-id"></p><p class="rcpt-note"></p></div></div>' +
'<div class="rcpt-actions"><button type="button" class="rcpt-btn">Print receipt</button><span class="rcpt-hint" aria-live="polite"></span></div>';
const $ = (s: string) => root.querySelector(s) as HTMLElement;
const feed = $('.rcpt-feed'), paper = $('.rcpt-paper'), btn = $('.rcpt-btn') as HTMLButtonElement, hint = $('.rcpt-hint');
let state: 'idle' | 'printing' | 'printed' | 'torn' = 'idle', raf = 0, alive = true;
let drag: { id: number; x: number; y: number; moved: boolean } | null = null;
/* ---------- content ---------- */
const money = new Intl.NumberFormat(opt.locale, { style: 'currency', currency: opt.currency });
function fill() {
$('.rcpt-title').textContent = opt.title;
$('.rcpt-date').textContent = new Date().toLocaleString(opt.locale, { dateStyle: 'medium', timeStyle: 'short' });
$('.rcpt-no').textContent = 'No. ' + opt.orderId.slice(-6);
const body = $('.rcpt-table tbody'); body.replaceChildren();
let sub = 0;
opt.items.forEach((it) => {
const qty = it.qty || 1, line = it.price * qty; sub += line;
const tr = document.createElement('tr'), a = document.createElement('td'), b = document.createElement('td');
a.textContent = it.name;
if (qty > 1) { const s = document.createElement('small'); s.textContent = ' ×' + qty; a.append(s); }
b.textContent = money.format(line) + (it.per ? '/' + it.per : '');
tr.append(a, b); body.append(tr);
});
const tax = Math.round(sub * opt.taxRate * 100) / 100;
$('.rcpt-sub').textContent = money.format(sub);
$('.rcpt-taxl').textContent = 'Tax (' + Math.round(opt.taxRate * 1000) / 10 + '%)';
$('.rcpt-tax').textContent = money.format(tax);
$('.rcpt-total').textContent = money.format(sub + tax);
$('.rcpt-id').textContent = '#' + opt.orderId;
$('.rcpt-note').textContent = opt.note;
/* decorative barcode drawn from the order number */
let x = 2, svg = '', seed = 7;
for (let i = 0; x < 196 && i < 400; i++) {
seed = (seed * 31 + opt.orderId.charCodeAt(i % opt.orderId.length)) % 997;
const w = 0.8 + (seed % 4) * 0.7, gap = 0.8 + ((seed >> 2) % 3) * 0.7;
if (x + w > 198) break;
svg += '<rect x="' + x.toFixed(2) + '" y="0" width="' + w.toFixed(2) + '" height="' + (seed % 5 === 0 ? 30 : 26) + '"/>'; x += w + gap;
}
$('.rcpt-bar').innerHTML = svg;
}
/* ---------- sound (made in code, plays only after a click) ---------- */
let ac: AudioContext | null = null;
function noise(len: number): AudioBuffer {
const a = ac as AudioContext, b = a.createBuffer(1, Math.floor(a.sampleRate * len), a.sampleRate), d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
}
function sound(kind: 'feed' | 'rip', dur = 0.1) {
if (!opt.sound) return;
try {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (!AC) return;
ac = ac || new AC();
const a = ac as AudioContext, t = a.currentTime, src = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain();
src.connect(f); f.connect(g); g.connect(a.destination);
if (kind === 'feed') {
/* thermal printer: a short buzzing burst per line */
src.buffer = noise(dur); f.type = 'bandpass'; f.frequency.value = 2300; f.Q.value = 3;
const lfo = a.createOscillator(), lg = a.createGain(); lfo.frequency.value = 95; lg.gain.value = 0.05; lfo.connect(lg); lg.connect(g.gain);
g.gain.setValueAtTime(0.06, t); g.gain.setValueAtTime(0.0001, t + dur); lfo.start(t); lfo.stop(t + dur); src.start(t); src.stop(t + dur);
} else {
/* paper rip: crackly noise sweeping down */
src.buffer = noise(0.32); f.type = 'bandpass'; f.Q.value = 0.9; f.frequency.setValueAtTime(4200, t); f.frequency.exponentialRampToValueAtTime(700, t + 0.3);
for (let i = 0; i < 12; i++) g.gain.setValueAtTime(i % 2 ? 0.08 : 0.32 + Math.random() * 0.2, t + i * 0.025);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.32); src.start(t); src.stop(t + 0.33);
}
} catch (e) { /* no audio: stay silent */ }
}
/* ---------- printing: the paper feeds out in small steps, like a real printer ---------- */
function setY(y: number, dx = 0, rot = 0) { paper.style.transform = 'translate(' + dx + 'px,' + y + 'px) rotate(' + rot + 'deg)'; }
function print() {
if (!alive) return;
cancelAnimationFrame(raf);
paper.getAnimations().forEach((a) => a.cancel());
root.classList.remove('is-torn'); paper.classList.remove('is-torn'); paper.style.opacity = '1';
fill();
const H = paper.offsetHeight;
feed.style.height = H + 'px';
state = 'printing'; root.classList.add('is-printing'); btn.disabled = true; btn.textContent = 'Printing…'; hint.textContent = '';
if (reduce) { setY(0); done(); return; }
const step = 18, steps = Math.ceil(H / step), stepMs = Math.max(55, Math.min(110, 2400 / steps));
let i = 0, t0 = -1;
setY(-H);
const tick = (now: number) => {
if (!alive) return;
if (t0 < 0) t0 = now;
const k = (now - t0) / stepMs, n = Math.floor(k), frac = Math.min(1, (k - n) / 0.55);
if (n > i) { i = n; if (i <= steps) sound('feed', stepMs / 1000 * 0.5); }
const shown = Math.min(H, (n + frac) * step);
setY(-H + shown);
if (shown >= H) { done(); return; }
raf = requestAnimationFrame(tick);
};
sound('feed', stepMs / 1000 * 0.5);
raf = requestAnimationFrame(tick);
}
function done() {
state = 'printed'; root.classList.remove('is-printing'); btn.disabled = false; btn.textContent = 'Tear off';
hint.textContent = 'Hold and pull the receipt down to tear it off';
root.dispatchEvent(new CustomEvent('receipt:printed', { bubbles: true, detail: { orderId: opt.orderId } }));
}
/* ---------- tearing ---------- */
function tear(dir: number) {
if (state !== 'printed') return;
state = 'torn'; sound('rip');
root.classList.add('is-torn'); paper.classList.add('is-torn');
const from = paper.style.transform || 'translate(0px,0px)';
const fall = paper.animate(
reduce ? [{ opacity: 1 }, { opacity: 0 }] : [{ transform: from, opacity: 1 }, { transform: 'translate(' + (dir * 46) + 'px,' + (feed.offsetHeight + 80) + 'px) rotate(' + (dir * 9 + 3) + 'deg)', opacity: 0 }],
{ duration: reduce ? 200 : 750, easing: 'cubic-bezier(.45,0,.8,.35)', fill: 'forwards' });
btn.textContent = 'Print again'; hint.textContent = 'Receipt torn off';
fall.onfinish = () => { if (alive) { paper.style.opacity = '0'; feed.style.height = '12px'; } };
root.dispatchEvent(new CustomEvent('receipt:torn', { bubbles: true, detail: { orderId: opt.orderId } }));
}
function nudge(text: string) { hint.textContent = text; hint.classList.remove('nudge'); void hint.offsetWidth; hint.classList.add('nudge'); }
function onDown(e: PointerEvent) {
if (state !== 'printed' || e.button > 0) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false };
try { paper.setPointerCapture(e.pointerId); } catch (err) { /* synthetic or finished pointer: dragging still works */ }
paper.style.transition = 'none';
}
function onMove(e: PointerEvent) {
if (!drag || e.pointerId !== drag.id) return;
const dx = e.clientX - drag.x, dy = Math.max(0, e.clientY - drag.y);
if (Math.hypot(dx, dy) > 4) drag.moved = true;
setY(dy * 0.35, dx * 0.12, dx * 0.025);
if (dy > 70 || Math.abs(dx) > 110) { const d = dx >= 0 ? 1 : -1; drag = null; tear(d); }
}
function onUp(e: PointerEvent) {
if (!drag || e.pointerId !== drag.id) return;
const moved = drag.moved; drag = null;
paper.style.transition = 'transform .35s cubic-bezier(.3,1.5,.5,1)'; setY(0);
setTimeout(() => { paper.style.transition = ''; }, 360);
nudge(moved ? 'Pull a little further to tear it' : 'Hold and pull down to tear it off');
}
const onBtn = () => { if (state === 'printed') tear(1); else if (state !== 'printing') print(); };
paper.addEventListener('pointerdown', onDown); paper.addEventListener('pointermove', onMove);
paper.addEventListener('pointerup', onUp); paper.addEventListener('pointercancel', onUp);
btn.addEventListener('click', onBtn);
fill(); feed.style.height = '12px'; setY(-paper.offsetHeight);
if (opt.autoPrint) print();
return () => { alive = false; cancelAnimationFrame(raf); root.innerHTML = ''; };
}
/** Shows a receipt that prints out of a slot after payment; people pull it down to tear it off. */
export function ReceiptPrinter({ onPrinted, onTorn, width = 290, ...options }: ReceiptOptions & {
onPrinted?: (orderId: string) => void;
onTorn?: (orderId: string) => void;
width?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const handlers = useRef({ onPrinted, onTorn });
handlers.current = { onPrinted, onTorn };
const key = JSON.stringify(options);
useEffect(() => {
const el = ref.current;
if (!el) return;
const printed = (e: Event) => handlers.current.onPrinted?.((e as CustomEvent).detail.orderId);
const torn = (e: Event) => handlers.current.onTorn?.((e as CustomEvent).detail.orderId);
el.addEventListener("receipt:printed", printed);
el.addEventListener("receipt:torn", torn);
const cleanup = mountReceipt(el, JSON.parse(key) as ReceiptOptions);
return () => { el.removeEventListener("receipt:printed", printed); el.removeEventListener("receipt:torn", torn); cleanup(); };
}, [key]);
return (
<>
<style>{css}</style>
<div ref={ref} className="rcpt" style={{ "--rcpt-w": `${width}px` } as CSSProperties} />
</>
);
}
/* Usage (after a successful payment):
<ReceiptPrinter currency="INR" taxRate={0.18} orderId={order.id}
items={order.lines.map((l) => ({ name: l.name, price: l.price, qty: l.qty }))}
onTorn={() => router.push("/orders")} />
*/
Install
npx shadcn@latest add https://ownlyplans.com/r/bhaskar/receipt-printer-pull-to-tear.json
Adds the React files to components/ownlyplans/ in a project set up with shadcn and Tailwind CSS. Or copy them from the Code tab.
About
The moment after checkout, made satisfying. A thermal receipt feeds out of a brushed-metal slot in small steps, like a real printer, with a soft buzz for each line and a green light while it prints. It lists what was bought with quantities, subtotal, tax and total (worked out from your data, formatted in any currency), plus a barcode and order number.
When it’s done, hold the receipt and pull it down (or sideways): it rips at the slot with a jagged edge, a paper-tear sound plays, and it drops away. Pull a little and let go and it springs back. Works with mouse and touch, and the Tear off button does the same for keyboard users; Print again prints a fresh one. The receipt is real text and a real table, so screen readers can read it. With reduced motion it appears and tears without moving.
Pass your own items, currency, tax rate, order number and note. It fires receipt:printed and receipt:torn events (onPrinted and onTorn in React) so you can move on to the order page.
MIT License
Copyright (c) 2026 OwnlyPlans
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.