This is the whole build of the receipt checkout, in the order it was written: the payment step of a made-up coffee roaster from Lisbon, Kōbō. The order summary is not a box of rows; it is a thermal receipt hanging out of a graphite printer. It feeds out line by line while the Pay amount counts up with it, a declined card prints an inverse DECLINED band, and an approved one prints APPROVED and a barcode, then the sheet tears off the serrated bar and pops forward. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework and no build step: one HTML page, one style sheet and two ES modules. Every word on the receipt is real DOM; the paper and the printer are one WebGL canvas under it.
1 · The checkout, and a receipt in CSS
The receipt is an article with a real table for the items and a description list for the totals, so a
screen reader reads an order summary, not a picture. Every size on it is in paper units: the sheet is 400 units wide,
and --u turns a unit into pixels for the stage it sits on.
<table class="r-items">
<caption class="sr">Items</caption>
<thead class="sr"><tr><th scope="col">Qty</th><th scope="col">Item</th><th scope="col">Price</th></tr></thead>
<tbody>
<tr class="ln" data-amt="16.50"><td>1</td><td>Ethiopia Guji 250g</td><td>€16.50</td></tr>
<tr class="ln" data-amt="96.00"><td>2</td><td>Ceramic cup, ash</td><td>€96.00</td></tr>
<tr class="ln" data-amt="32.00"><td>1</td><td>V60 dripper, clay</td><td>€32.00</td></tr>
<tr class="ln" data-amt="6.50"><td>1</td><td>Filter papers x100</td><td>€6.50</td></tr>
</tbody>
</table>
.paper {
position: absolute; left: 0; top: 0; width: calc(var(--u) * 400px); transform-origin: 0 0; pointer-events: none;
font-family: var(--mono); color: var(--thermal); visibility: hidden;
}
.r-total { height: calc(var(--u) * 32px); line-height: calc(var(--u) * 32px); font-size: calc(var(--u) * 22px); font-weight: 700; letter-spacing: .01em; }
The form is a real one: labels, autocomplete="cc-number" and friends, a numeric keypad on phones,
grouping as you type, a network read from the first digits, and a Luhn check before anything is sent. The card brands
here are made up.
const NETS = [
{ id: 'prism', name: 'Prism', test: /^3[47]/, len: 15, gaps: [4, 10], cvc: 4 },
{ id: 'nova', name: 'Nova', test: /^(6011|64[4-9]|65)/, len: 16, gaps: [4, 8, 12], cvc: 3 },
{ id: 'orbit', name: 'Orbit', test: /^(5[1-5]|2(2[2-9]|[3-6]\d|7[01]|720))/, len: 16, gaps: [4, 8, 12], cvc: 3 },
{ id: 'lumen', name: 'Lumen', test: /^4/, len: 16, gaps: [4, 8, 12], cvc: 3 },
];
const luhn = (d) => { let s = 0; for (let i = 0; i < d.length; i++) { let x = +d[d.length - 1 - i]; if (i % 2) { x *= 2; if (x > 9) x -= 9; } s += x; } return d.length > 0 && s % 10 === 0; };
Printing is a length. S.L is how much paper has left the slot; the sheet is moved up by it and clipped at
the slot. The feed does not glide: its speed is a pulse, so the paper steps out one line per head pulse, the leader and
the header faster.
function print(toL) {
if (RM) { S.L = toL; develop(true); kick(); return Promise.resolve(); }
return new Promise((res) => {
S.printing = true; let t = 0; setLed('busy');
tasks.add((dt) => {
t += dt;
const v = S.L < S.L0 - 2 ? 360 : 150; // leader + header feed fast, then one line per head pulse
const speed = v * (1 - Math.cos(PI * 2 * 7 * t));
S.L = Math.min(toL, S.L + speed * dt); develop(false);
if (S.L >= toL - 1e-3) { S.L = toL; S.printing = false; develop(false); res(); return false; }
return true;
});
kick();
});
}
A line is printed grey and develops to full black the moment it clears the head. Lines with a price add it to the Pay amount, which eases towards it, so the button counts up with the receipt.
.paper .ln.wet { color: #a59f98; }
.paper .bean.wet { opacity: .45; }
function develop(instant) {
for (const ln of S.lines) {
if (!ln.n.classList.contains('wet') || ln.y1 > S.L + 2) continue;
if (instant) { ln.n.style.transition = 'none'; ln.n.classList.remove('wet'); void ln.n.offsetWidth; ln.n.style.transition = ''; }
else ln.n.classList.remove('wet');
if (ln.amt) S.amtT = Math.round((S.amtT + ln.amt) * 100) / 100;
if (instant) { S.amt = S.amtT; setAmt(S.amt); }
}
}
// the Pay amount counts line by line
if (Math.abs(S.amtT - S.amt) > .004) { act = true; S.amt += (S.amtT - S.amt) * (1 - Math.exp(-dt * 14)); if (Math.abs(S.amtT - S.amt) < .006) S.amt = S.amtT; setAmt(S.amt); }
Pay authorises, then prints an approval block onto the same sheet (a rule, APPROVED with a code, a barcode drawn from the order number and a thank-you) and tears it off.
S.phase = 'printing';
el.payLbl.textContent = 'Printing receipt…';
block('ok'); await print(S.L1);
setLed('ready');
await wait(260);
S.phase = 'tearing';
await tear();
let bars = '', x = 0, seed = S.order;
while (x < 230) { seed = (seed * 9301 + 49297) % 233280; const w = 1.2 + (seed % 4) * 1.1; if (x + w > 232) break; bars += `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="30"/>`; x += w + 1.4 + (seed % 3) * 1.2; }
The tear is three moves: tension against the bar, the rip from left to right with the sheet pivoting on the corner that is still attached, then the free sheet pops forward on springs and settles hanging a little crooked.
// 1. tension: the hand pulls the sheet up and forward against the tear bar
await tween(230, (k) => { const e = easeIO(k); T.pose.dy = -3 * e; T.pose.rx = 2.6 * e; });
// 2. rip: left to right, the sheet pivots on the corner that is still attached
await tween(300, (k) => {
const e = easeIO(k), rz = 2.2 * e, a = rz * PI / 180;
T.pose.rz = rz; T.pose.dx = 200 * (1 - Math.cos(a)); T.pose.dy = -3 - 200 * Math.sin(a) - 4 * e;
});
// 3. free: it pops forward and up, then settles hanging in the light with a deeper shadow
T.k = 120; T.c = 13;
T._t = { dx: 0, dy: -34, dz: 46, rx: -4, rz: -3.2 }; T._v = { dx: -40, dy: -160, dz: 120, rx: -30, rz: -26 };
function runSprings(T, dt) {
let act = false;
for (const key in T._t) {
const tg = T._t[key]; let v = T._v[key]; const k = T.k || 140, c = T.c || 15;
const sub = 4, h = dt / sub;
for (let i = 0; i < sub; i++) { v += (k * (tg - T.pose[key]) - c * v) * h; T.pose[key] += v * h; }
T._v[key] = v;
if (Math.abs(tg - T.pose[key]) < 2e-3 && Math.abs(v) < 2e-3) { T.pose[key] = tg; T._v[key] = 0; } else act = true;
}
return act;
}
The pose becomes one matrix about the attached corner. At rest it is a plain 2D matrix, so the text stays crisp; in flight it gets the same perspective the paper will get in stage 2.
function tornModel(T, withPaperOrigin) {
const u = G.u, p = T.pose;
const pivX = T.left + 200 * u, pivY = T.slotP;
let m = new DOMMatrix().translate(pivX + p.dx * u, pivY + p.dy * u, p.dz * u)
.rotateAxisAngle(0, 0, 1, p.rz).rotateAxisAngle(1, 0, 0, p.rx).translate(-pivX, -pivY, 0);
if (withPaperOrigin) m = m.translate(T.left, T.slotP - T.L * u);
return m;
}
const o = [G.W / 2, G.H / 2], F = 2300 * u;
const P = new DOMMatrix(); P.m34 = -1 / F;
const M = new DOMMatrix().translate(o[0], o[1]).multiply(P).translate(-o[0], -o[1]).multiply(tornModel(T, true));
el.paper.style.transform = M.toString();
The loop runs only while the paper feeds, a tween or a spring moves, or the amount still counts; then it sleeps.
function frame(now) {
raf = 0; const dt = last ? Math.min(.05, (now - last) / 1000) : 1 / 60; last = now;
for (const t of [...tasks]) if (!t(dt, now)) tasks.delete(t);
const active = easeAll(dt);
draw(now);
if (tasks.size || active || S.printing) kick(); else last = 0;
}

2 · Paper and printer in WebGL
One transparent canvas goes under the receipt. The DOM text stays where it is and is multiplied onto the canvas, so the paper's light and shade fall on the ink.
<canvas id="gl" aria-hidden="true"></canvas>
.paper .ink { position: relative; mix-blend-mode: multiply; padding: 0 calc(var(--u) * 26px); -webkit-mask-image: var(--speckle); mask-image: var(--speckle); -webkit-mask-size: calc(var(--u) * 180px); mask-size: calc(var(--u) * 180px); }
The paper is a mesh: a flat body, then the free end rolled back over a cylinder into a lip, with a short tail. The length that has been printed decides how much of the end is rolled.
float t = (a.y - 1.)*uCurl; s = s1 + t; float arc = 3.14159265*uRc;
if (t <= arc) { float ph = t/uRc; p = vec3(x, -(s1 + uRc*sin(ph)), uRc*(1. - cos(ph))); n = vec3(0., sin(ph), cos(ph)); }
else { float tl = t - arc; p = vec3(x, -s1 + tl, 2.*uRc); n = vec3(0., 0., -1.); }
The canvas uses the CSS projection itself (the same perspective distance and origin), so the mesh lands exactly under the DOM text, and in flight both get the same matrix.
vec4 w = uModel*vec4(uLeft + p.x*uU, uSlot + p.y*uU, p.z*uU, 1.);
vW = w.xyz; vN = mat3(uModel)*n; vTx = mat3(uModel)*vec3(1., 0., 0.); vP = vec2(x, s);
float ww = 1. - w.z/uF; vec2 scr = uO + (w.xy - uO)/ww;
gl_Position = vec4((scr.x/uRes.x*2. - 1.)*ww, (1. - scr.y/uRes.y*2.)*ww, -w.z/4000.*ww, ww);
The torn edges are cut in the fragment shader: a zigzag with a little random jag per tooth, antialiased with
fwidth. Under the lip the paper is shaded where the lip throws its shadow.
float per = 400./22.;
float row = uL - vP.y; // material row, measured from the free end
float jag = h11(floor(vP.x*1.6));
float zt = uZigTop*(1. - tri(vP.x/per)) + .9*jag*step(.01, uZigTop);
float zb = uBotCut + uZigBot*tri(vP.x/per) + .9*h11(floor(vP.x*1.6) + 11.)*step(.01, uZigBot);
float fw = clamp(fwidth(row), 1e-3, 2.5)*.8;
float cov = clamp((row - zt)/fw + .5, 0., 1.)*clamp((vP.y - zb)/fw + .5, 0., 1.);
if (cov <= .002) discard;
// the lip's shadow on the flat paper below its torn edge (shifted right by the light)
float tail = max(uCurl - 3.14159265*uRc, 0.);
float re = uCurl + tail;
float zl = uZigTop*(1. - tri((vP.x - 20.)/per));
float d = row - (re - zl);
float d2 = row - re;
col *= 1. - flatK*uLipShade*(.13*smoothstep(-1., 2., d)*exp(-max(d, 0.)/6.) + .06*smoothstep(-6., 4., d2)*exp(-max(d2, 0.)/26.));
Soft shadows are analytic: the shadow of a box blurred by a Gaussian is a product of two error functions, so a soft contact shadow and a wide ambient one cost a few instructions per pixel, with no blur pass.
vec4 erf4(vec4 x){ vec4 s = sign(x), a = abs(x); x = 1. + (.278393 + (.230389 + .078108*(a*a))*a)*a; x *= x; return s - s/(x*x); }
float boxSh(vec2 p, vec2 lo, vec2 hi, float sg){ vec4 i = .5 + .5*erf4(vec4(p - lo, p - hi)*(.70710678/sg)); return max((i.x - i.z)*(i.y - i.w), 0.); }
float pr = boxSh(p, c - b + vec2(14., 26.)*uU, c + b + vec2(-14., 30.)*uU, 24.*uU)*.34
+ boxSh(p, c - b + vec2(6., 8.)*uU, c + b + vec2(-6., 7.)*uU, 6.*uU)*.30;
vec2 q = rot(-uPT.x)*(p - uPiv - uPT.yz) + uPiv;
float lf = 1. + uLift;
float pa = boxSh(q, uPR.xy + vec2(4., 5.)*uU*lf, uPR.zw + vec2(4., 5.)*uU*lf, (3.5 + 5.*uLift)*uU)*.20
+ boxSh(q, uPR.xy + vec2(22., 16.)*uU*lf, uPR.zw + vec2(22., 16.)*uU*lf, (15. + 10.*uLift)*uU)*.24
+ boxSh(q, uLR.xy + vec2(30., 26.)*uU, uLR.zw + vec2(30., 26.)*uU, 12.*uU)*.26;
pa *= uPA;
The printer is a signed-distance rounded box in soft-touch graphite: a satin top band, a bevel, the slot the paper runs into, a serrated steel tear bar and the LED.
// tear bar teeth, a hair below the groove
float bt = uSlot + 2.*gh + 1.8*uU;
float tooth = 1.6*uU*(1. - abs(fract((p.x - gc.x)/(3.2*uU)) - .5)*2.);
float inBar = step(bt - tooth, p.y)*step(p.y, bt + 1.6*uU)*step(abs(p.x - gc.x), gw - 2.*uU);
vec3 steel = vec3(.50, .50, .49) + vec3(.22)*smoothstep(bt + 1.6*uU, bt - tooth, p.y) + (hash(vec2(floor(p.x*uDpr), 3.)) - .5)*.08;
// status LED
vec2 lc = vec2(c.x + b.x - 56.*uU, c.y + 10.*uU);
float ld = length(p - lc), lr = max(5.6*uU, 4.6);
float inL = clamp((lr - ld)*uDpr + .5, 0., 1.);
vec3 lens = mix(vec3(.05), uLed.rgb*(1.15 - .45*ld/lr), uLed.a > .02 ? 1. : 0.) + vec3(.55)*exp(-length(p - lc + vec2(1.6, 1.8)*uU)/(1.1*uU));
col = mix(col, lens, inL);
col += uLed.rgb*uLed.a*.42*exp(-max(ld - lr, 0.)/(7.*uU))*(1. - inL);
Each frame hands the renderer the paper's length, its curl, its pose and the light.
const fr = {
u, O: [G.W / 2, G.H / 2], F: 2300 * u, light: [S.light[0] + S.lightX, S.light[1], S.light[2]], warm: S.warm,
printer: { cx: G.cx, cy: G.pcy, hw: G.hw, hh: G.hh }, slot: G.slot, px: [G.left, G.left + 400 * u], led: ledColor(now),
buzz: S.printing && !RM ? Math.sin(t * 330) * .35 : 0,
};
With a mouse the light follows the pointer and the lip lifts a hair.
el.stage.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse' || RM) return;
const r = el.stage.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top, u = G.u;
const L = S.torn ? S.torn.L : S.L;
const top = G.slot - (L - LIP_TOP) * u;
const inside = x > G.left - 20 * u && x < G.left + 420 * u && y > top - 30 * u && y < G.slot;
if (inside) {
const nx = (x - (G.left + 200 * u)) / (200 * u), ny = (y - (top + G.slot) / 2) / ((G.slot - top) / 2);
S.tiltT = [-nx * .05, -ny * .035]; S.RcT = RC + 1.8; S.lightXT = nx * .22;
} else { S.tiltT = [0, 0]; S.RcT = RC; S.lightXT = 0; }
kick();
});
Without WebGL the stage 1 receipt and printer take over, with the same layout and the same flow.
let renderer = null;
if (!el.html.classList.contains('no-gl')) {
try { renderer = createRenderer(el.canvas); } catch (e) { console.warn('[dv81]', e); renderer = null; }
if (!renderer) el.html.classList.add('no-gl');
}

3 · The finish
The email is kept under one versioned key so a returning customer does not type it again. Card data is never stored. Older keys of the component are wiped before the first paint.
// storage: one versioned key (dv81.v1, the email only); anything older is wiped. The theme is the page's own (light).
try {
for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && /^dv81/.test(k) && k !== 'dv81.v1') localStorage.removeItem(k); }
} catch (e) {}
function save() { try { const v = F.email.value.trim(); if (/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) localStorage.setItem(KEY, JSON.stringify({ email: v })); } catch (e) {} }
function load() { try { const o = JSON.parse(localStorage.getItem(KEY) || 'null'); if (o && typeof o.email === 'string' && o.email.length < 120) F.email.value = o.email; } catch (e) {} }
A lost WebGL context drops to the CSS receipt instead of a blank stage, and a small test hook reports the state.
if (renderer) el.canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); renderer = null; el.html.classList.add('no-gl'); layout(); draw(performance.now()); });
window.__receipt = {
ready,
get busy() { return !!raf || tasks.size > 0 || S.busy || S.printing; },


Make it yours
- Your order is the receipt markup in
index.html: one table row per item with its price indata-amt(that is what the Pay button counts), then the totals. - Your paper is in
gl.js: the albedo of the sheet, the lip's radius and the number of torn teeth. A pink ticket stock is one colour away. - Your payment goes in
pay(): replace the simulated authorisation with your payment provider and keep the print, decline and tear as the answer. - Accessibility is already there: labels on every field, inline errors with
aria-describedby, a live status line, focus moved to the field that needs it, anaria-disabledPay that explains itself, 44 px targets on touch,prefers-reduced-motion(the receipt opens printed, the tear resolves at once) and the CSS receipt without WebGL.
Kōbō and its card networks are made up, and the Pay button charges nothing. Everything on the page was made in code for this project: the paper and the printer are rendered by the shader, the fallback is CSS.
Make the summary a thing. A receipt that feeds, counts and tears is the same data as a table of rows, but the customer watches the total being printed, and paying has a moment you can see.