Aduok Code

A Ticket Printer, Built from a Gold Gradient Slot, a Stepped Paper Feed, a Canvas Confetti Burst, and a Zigzag clip-path Tear Along the Perforation

Introduction

This ticket printer has no animation library, no image assets, and no sound files. A gold machine is a few gradients, the receipt feeds out of its slot because one element moves behind a clipping box, the confetti is a small canvas loop, and the final tear is two copies of the receipt clipped along a zigzag line that sits exactly on the dashed perforation. Everything is one self-contained HTML file.

The effect is a short story told in four beats: the paper prints slowly, it finishes with a celebration, it waits for you, and then it rips. Each beat is a separate, small piece of code, and a four-word state variable (printing, ready, tearing, torn) decides which piece is allowed to run.

What you will learn

How to fake a slot with stacked z-index layers — how the Web Animations API can feed paper in uneven pushes — how pseudo-elements punch notches into a receipt — how a canvas loop animates confetti with gravity — and how two clip-path polygons built from the same zigzag points make a believable tear.

01
Part One
A Slot Built from Layers

Step 1 — The Paper Lives Between Two Gold Layers

The machine is not one shape. It is a gold body, a dark strip that reads as the slot opening, a thin gold lip below it, and a transparent window that holds the paper. The paper sits in a z-index sandwich: the dark strip is at the back, the paper is in front of it, and the lip is in front of the paper. Because the lip overlaps the top of the receipt, the paper looks like it is emerging from inside the machine.

CSS
.m-body {
  height: 64px;
  border-radius: 24px 24px 6px 6px;
  background: linear-gradient(180deg,
    var(--gold-1) 0%, var(--gold-2) 38%,
    var(--gold-3) 72%, var(--gold-4) 100%);
  box-shadow: inset 0 2px 0 rgba(255,255,255,.75);
  position: relative;
  z-index: 3;
}

.m-gap  { position: absolute; top: 60px; height: 12px;
          left: 14px; right: 14px;
          background: var(--gold-dark); z-index: 1; }

.window { position: relative; z-index: 2; overflow: hidden;
          padding: 0 22px 30px; }

.m-lip  { position: absolute; top: 70px; height: 18px;
          left: 0; right: 0; z-index: 5;
          border-radius: 3px 3px 16px 16px; }

The metallic look comes from the gradient alone: a pale gold at the top, a deeper band in the middle, and a darker gold at the bottom, plus a one-pixel white inset shadow on the top edge that catches the light. A status light in the corner is a small circle whose color is a CSS variable, green when idle and orange while printing, with a color-mix() glow behind it.

02
Part Two
A Paper Feed That Stutters

Step 2 — Slide the Paper Up Into a Clipping Window

The .window element has overflow: hidden, and the .paper inside it starts translated up by its own full height, so none of it is visible. Printing is just animating that translate back to zero. Nothing is created or removed, which is why the same animation can run again when you press the reprint button.

A single smooth ease would look like a sliding panel, not a printer. A real printer pushes the paper a little, pauses, and pushes again. The Web Animations API accepts a list of keyframes with explicit offsets, so the feed is built as sixteen short moves, each followed by a hold of random length.

JavaScript
const H = paper.offsetHeight;
const N = 16;
const kf = [{ transform: `translateY(${-H}px)`, offset: 0 }];

for (let i = 1; i <= N; i++) {
  const hold  = i === N ? 0 : 0.18 + Math.random() * 0.3;
  const start = (i - 1) / N, end = i / N;
  const moveEnd = start + (end - start) * (1 - hold);
  const y = -H * (1 - i / N);

  kf.push({ transform: `translateY(${y}px)`, offset: i === N ? 1 : moveEnd });
  if (i !== N) kf.push({ transform: `translateY(${y}px)`, offset: end });
}

// moving segments ease; hold segments stay flat
for (let k = 0; k < kf.length - 1; k++) {
  kf[k].easing = kf[k].transform === kf[k + 1].transform
    ? "linear"
    : "cubic-bezier(.45,0,.25,1)";
}

const anim = paper.animate(kf, { duration: 6800, fill: "forwards" });
anim.onfinish = () => {
  paper.style.transform = "translateY(0)"; // keep the final pose
  anim.cancel();                            // release the animation
};
Why finish with an inline style

A fill-forwards animation keeps overriding the element’s own transform for as long as it exists. Writing the final transform inline and then cancelling the animation hands control back to normal CSS, so the hover tilt added later can use the same property tree without fighting it.

03
Part Three
The Receipt, Notches, and Barcode

Step 3 — Half-Circles Cut from the Page Colour

The dashed line across the receipt is an empty zero-height element with a dashed top border. It stretches slightly past the receipt on both sides using negative horizontal margins. Its two pseudo-elements are 22px circles painted in the page background colour, centred on the receipt’s left and right edges, so each one looks like a bite taken out of the paper.

CSS
.perf {
  position: relative;
  margin: 26px -24px 24px;
  height: 0;
  border-top: 1.5px dashed var(--paper-line);
}

.perf::before,
.perf::after {
  content: "";
  position: absolute;
  top: -12px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--ground);
}
.perf::before { left: -11px; }
.perf::after  { right: -11px; }

The barcode is generated, not drawn. A tiny seeded random number generator picks bar widths from [1, 1, 2, 3] and gaps from [1, 1, 2] until a 200-unit viewBox is full. Seeding it means the stripes are identical on every visit and in every copy of the receipt, which matters later when the receipt is cloned for the tear. preserveAspectRatio="none" lets the same bars stretch to any receipt width.

JavaScript
let seed = 73052849;
const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;

let x = 0, out = "";
while (x < 200) {
  const w = [1, 1, 2, 3][Math.floor(rnd() * 4)];
  if (x + w > 200) break;
  out += `<rect x="${x}" y="0" width="${w}" height="54" fill="#15162a"/>`;
  x += w + [1, 1, 2][Math.floor(rnd() * 3)];
}
svg.innerHTML = out;

One more decision keeps the page theme-safe: the paper tokens (--paper, --paper-ink, --paper-muted) are defined once and never change. Only the stage behind the machine switches between light and dark, so the receipt always looks like white paper.

04
Part Four
A Confetti Burst on Canvas

Step 4 — 150 Particles, Gravity, and Drag

When the feed animation finishes, burst() creates 150 particles along the slot, each with a random upward angle, speed, size, spin, colour, and shape (rectangle, circle, or triangle). A fixed full-screen canvas with pointer-events: none sits above the page, so the confetti passes over everything without blocking a click.

JavaScript
function tick() {
  ctx.clearRect(0, 0, innerWidth, innerHeight);
  parts = parts.filter(p => p.life < p.max && p.y < innerHeight + 30);

  for (const p of parts) {
    p.life++;
    p.vy += 0.36;                 // gravity
    p.vx *= 0.985; p.vy *= 0.985; // air drag
    p.x += p.vx;   p.y += p.vy;
    p.r += p.vr;                  // spin
    // ...draw the rotated shape with a fading globalAlpha
  }
  raf = parts.length ? requestAnimationFrame(tick) : 0;
}

The loop removes particles that have lived out their lifetime or fallen below the screen, and it stops requesting frames entirely when the array is empty. A page that is sitting still therefore costs nothing. The canvas is sized with the device pixel ratio so the pieces stay sharp on high-density screens.

05
Part Five
Tearing Along the Perforation

Step 5 — Two Clones, One Shared Zigzag

CSS cannot split an element in two, so the tear is a magic trick. On click, the receipt is cloned twice and the clones are placed exactly over the original, which is then hidden. One clone is clipped to everything above a zigzag line, the other to everything below it. Because both clip-paths are built from the same list of points, the two halves match along the torn edge like real paper.

The line always runs through the dashed perforation. Its height is measured from the perforation element itself instead of from the click position, so the receipt tears at the same place no matter where you click.

JavaScript
const r  = receipt.getBoundingClientRect();
const pr = receipt.querySelector(".perf").getBoundingClientRect();
const ty = pr.top - r.top + pr.height / 2;   // y of the perforation

// zigzag points from right to left
const pts = [];
for (let x = W, i = 0; x > 0; x -= 8 + Math.random() * 5, i++) {
  pts.push([x, ty + (i % 2 ? 3 : -3) + (Math.random() - 0.5) * 3]);
}
pts.push([0, ty]);

const edgeRtl = pts.map(p => `${p[0]}px ${p[1]}px`).join(",");
const edgeLtr = pts.slice().reverse().map(p => `${p[0]}px ${p[1]}px`).join(",");

const topPoly = `polygon(0px 0px, ${W}px 0px, ${edgeRtl})`;
const botPoly = `polygon(${edgeLtr}, ${W}px ${H}px, 0px ${H}px)`;

Each clone is wrapped twice. The inner wrapper carries the clip-path. The outer wrapper carries filter: drop-shadow(...) and the animation. The order matters: CSS applies filters before clip-paths, so a shadow on the clipped element itself would be cut off. Putting the shadow on a parent lets it follow the jagged edge.

JavaScript
top.animate([
  { transform: "none", offset: 0 },
  { transform: "translate(-8px,-14px) rotate(-3deg)", offset: 0.16 },
  { transform: `translate(${-vw * 0.6}px,${-(r.top + H + 240)}px) rotate(-42deg)` }
], { duration: 1050, fill: "forwards" });

bot.animate([
  { transform: "none", offset: 0 },
  { transform: "translate(10px,12px) rotate(4deg)", offset: 0.16 },
  { transform: `translate(${vw * 0.5}px,${vh - r.top + 260}px) rotate(34deg)` }
], { duration: 1150, fill: "forwards" });

The small first keyframe is what sells the tear. For about a sixth of the animation the two halves only twitch apart, and then gravity takes over. Without that pause, the receipt looks like it was deleted instead of ripped.

The rip sound is also generated. A 0.38-second buffer of random noise is shaped by a falling envelope, made gritty by keeping only some of the samples at full strength, and passed through a band-pass filter around 2.2kHz. It starts inside the click handler, which is the only moment a browser lets a page begin audio.

06
Part Six
State, Tilt, and Accessibility

Step 6 — A Four-State Machine and a Hover Tilt

A single state variable moves through printing, ready, tearing, and torn. Clicks are ignored unless the state is ready, so you cannot tear a half-printed receipt or tear one twice. The reprint button simply sets the state back to printing and runs the feed again on the same elements.

While the state is ready, moving the pointer across the receipt rotates it by up to 2.5 degrees, with the rotation pivoting from the top centre where the paper leaves the slot. The tilt lives on a separate inner wrapper so it never collides with the feed transform on the outer one.

Keyboard, status, and reduced motion

Once the receipt is ready it becomes a focusable element with role=button and an accessible name, and Enter or Space tears it along the same perforation. A status line with aria-live announces printing and torn states. Under prefers-reduced-motion the feed shortens to half a second, the confetti is skipped, and the two halves simply fade out.

Tuning Reference

PropertyExampleEffect
Feed duration6800 msTotal time for the receipt to print; the stutter is spread across it
Feed chunks (N)16Number of separate pushes; fewer feels chunkier, more feels smoother
Hold fraction0.18 + random * 0.3How much of each chunk is spent paused
Particle count150Size of the confetti burst
Gravity / drag0.36 / 0.985How fast confetti falls and how quickly it loses speed
Zigzag step / amplitude8–13px / ±3pxRoughness of the torn edge
Fly-off durations1050 / 1150 msHow long each torn half takes to leave the screen
Tilt strengthpointer offset * 5degMaximum hover rotation of the finished receipt

Full Source Code

The whole project is a single self-contained HTML file — markup, styles, script, and generated sound, with no build step and no external requests apart from one font stylesheet.

HTML — ticket-printer.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/ticket-printer.html
Want to try it yourself?Open the live ticket-printer.html file