Library / Tutorial

Receipt checkout,
step by step.

A checkout whose order summary is a thermal receipt. It feeds out of a graphite printer line by line while the Pay amount counts up, prints APPROVED and a barcode, and tears off the serrated bar when you pay. Every word stays DOM; the paper and the printer are WebGL. Three stages, each a live page you can run.

3Stages
1Canvas
0Libraries

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.

index.html — the items
<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>
receipt.css — the sheet in paper units
.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;
}
receipt.css — the total line
.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.

app.js — card networks
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 },
];
app.js — the Luhn check
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.

app.js — feed the paper
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.

receipt.css — a line still under the head
.paper .ln.wet { color: #a59f98; }
.paper .bean.wet { opacity: .45; }
app.js — develop what has cleared the slot
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); }
  }
}
app.js — count the amount up
// 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.

app.js — approved
S.phase = 'printing';
el.payLbl.textContent = 'Printing receipt…';
block('ok'); await print(S.L1);
setLed('ready');
await wait(260);
S.phase = 'tearing';
await tear();
app.js — a barcode from the order number
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.

app.js — tension, rip, free
// 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 };
app.js — the springs
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.

app.js — the torn sheet's matrix
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;
}
app.js — perspective while it flies
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.

app.js — one frame
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;
}
Stage 1 · the checkout and CSS receiptOpen ↗
The Kōbō checkout: the title Almost yours., the email, card, expiry and CVC fields and the Pay button on the left; on the right a CSS receipt with APPROVED and a barcode, torn off and lifted over a dark CSS printer.

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.

index.html — the canvas
<canvas id="gl" aria-hidden="true"></canvas>
receipt.css — ink on paper
.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.

gl.js — the rolled lip
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.

gl.js — the CSS projection
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.

gl.js — torn teeth
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;
gl.js — the lip's shadow
// 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.

gl.js — a blurred box, exactly
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.); }
gl.js — printer and paper shadows
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.

gl.js — the tear bar
// 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;
gl.js — the LED
// 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.

app.js — what a frame draws
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.

app.js — the pointer light
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.

app.js — WebGL, or the CSS receipt
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');
}
Stage 2 · paper and printer in WebGLOpen ↗
The checkout with the WebGL receipt: warm fibre paper with a rolled lip and torn teeth, APPROVED and a barcode printed, the sheet just torn off the serrated steel bar and popping forward with a deeper shadow over the graphite printer.

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.

index.html — wipe old keys
// 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) {}
app.js — keep the email
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.

app.js — a lost context
if (renderer) el.canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); renderer = null; el.html.classList.add('no-gl'); layout(); draw(performance.now()); });
app.js — the test hook
window.__receipt = {
  ready,
  get busy() { return !!raf || tasks.size > 0 || S.busy || S.printing; },
Stage 3 · the finished checkoutOpen ↗
The finished checkout: the receipt with APPROVED and a barcode torn off the printer and lifted towards the viewer, its shadow on the stage, the Pay button printing the receipt.
Stage 3 · on a phoneOpen ↗
The checkout on a phone: Almost yours., a full-width Pay €155.90 and Paying with Lumen 4242 above the whole receipt hanging out of the graphite printer, TOTAL €155.90 printed.

Make it yours

  • Your order is the receipt markup in index.html: one table row per item with its price in data-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, an aria-disabled Pay 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.

The one idea worth keeping

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.