GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Scratch Card Reveal: 9 Pixel-Calculated Patterns

Observation log 09:00 — a fingertip lands and item 03's foil flakes catch gravity. 09:04 — item 06's progress ring stalls at 62%.

Auto-plays · click a tile to jump to its section · all nine in one zip

Scratch Card Reveal: How One Erased-Area Value Changes the Verdict

Every one of these nine builds answers the same question with a different number: how much of the foil has been erased right now? Item 01 reads that ratio straight off the canvas pixels and auto-peels the rest past a threshold; item 06 reuses the identical formula to fill a ring instead of opening a card; item 07 waits for the same ratio to approach 100% before it fires confetti. None of the nine reach for a physics library or a tween plugin to get there — getImageData, lerp, and a seeded pseudo-random generator do the whole job.

01Card That Auto-Peels Once Pixel-Measured Area Passes a Threshold

clearedPixelRatio() walks the canvas's alpha channel pixel by pixel and counts how many are below an opacity of 40, so the "erased" figure is measured, not estimated from stroke length. Once that ratio times 100 clears SCRATCH_REVEAL_THRESHOLD_PCT, autoPeelRemaining() calls openCard() and wipes whatever foil is left in one destination-out fill.

Card That Auto-Peels Once Pixel-Measured Area Passes a Threshold
Open in a new tab

01 · Card That Auto-Peels Once Pixel-Measured Area Passes a Threshold — Measured area 12.103% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

clearedPixelRatioSCRATCH_REVEAL_THRESHOLD_PCTautoPeelRemaining
  function clearedPixelRatio() {
    var data = ctx.getImageData(0, 0, foil.width, foil.height).data;
    var clear = 0, total = data.length / 4;
    for (var i = 3; i < data.length; i += 4) { if (data[i] < 40) clear += 1; }
    return clear / total;
  }

  function updateMeter(ratio) {
    meterFill.style.width = Math.round(ratio * 100) + '%';
  }

  function openCard() {
    if (opened) return;
    opened = true;
    ctx.globalCompositeOperation = 'destination-out';
    ctx.fillRect(0, 0, foil.width, foil.height);
    updateMeter(1);
    root.classList.add('is-open');
    live.textContent = 'The 20% off coupon is revealed';
  }

  function autoPeelRemaining(ratio) {
    if (ratio * 100 >= SCRATCH_REVEAL_THRESHOLD_PCT) openCard();

Item 01 repainted 12.1% of the frame at an average intensity of 40.4 across 15/23 measured frames.

02Foil Texture Procedurally Drawn With a Different Pattern Each Time

buildNoiseFoil(seed) builds a lookup table once per card and samples it through valueNoise2D(), a bilinear interpolation between four noiseAt() corners, so the sheen never repeats between two cards drawn from different seeds. The result is written pixel by pixel into an ImageData buffer rather than laid down as a CSS gradient.

Foil Texture Procedurally Drawn With a Different Pattern Each Time
Open in a new tab

02 · Foil Texture Procedurally Drawn With a Different Pattern Each Time — Measured area 28.594% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

valueNoise2DbuildNoiseFoilnoiseAt
  function valueNoise2D(table, x, y) {
    var x0 = Math.floor(x), y0 = Math.floor(y);
    var tx = smoothstep(x - x0), ty = smoothstep(y - y0);
    var v00 = noiseAt(table, x0, y0), v10 = noiseAt(table, x0 + 1, y0);
    var v01 = noiseAt(table, x0, y0 + 1), v11 = noiseAt(table, x0 + 1, y0 + 1);
    var a = v00 + (v10 - v00) * tx, b = v01 + (v11 - v01) * tx;
    return a + (b - a) * ty;
  }

  function buildNoiseFoil(seed) {
    var table = buildNoiseTable(seed);
    var crackRand = seedRand(seed + 1);
    var img = ctx.createImageData(FW, FH);
    var d = img.data;
    var scaleX = FOIL_NOISE_DIM / FW, scaleY = FOIL_NOISE_DIM / FH;
    for (var y = 0; y < FH; y += 1) {
      for (var x = 0; x < FW; x += 1) {
        var n = valueNoise2D(table, x * scaleX * 2.4, y * scaleY * 2.4);
        var lightness = 176 + n * 62;
        var idx = (y * FW + x) * 4;

Item 02 repainted 28.6% of the frame at an average intensity of 37.2 across 17/23 measured frames.

03Foil Flakes That Fly Off Under Gravity

spawnFlake(x, y, vx, vy) gives each broken-off flake a random rotation and spin from a seeded generator, then stepFlakes() adds FLAKE_GRAVITY_PX_S2 to its vertical velocity every tick until FLAKE_LIFE_MS expires or it drifts past the card's edge. Under prefers-reduced-motion, spawnFlake() returns immediately and no flake is ever created.

Foil Flakes That Fly Off Under Gravity
Open in a new tab

03 · Foil Flakes That Fly Off Under Gravity — Measured area 12.323% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

spawnFlakestepFlakesFLAKE_LIFE_MS
  function spawnFlake(x, y, vx, vy) {
    if (reduce) return;
    flakes.push({
      x: x, y: y, vx: vx, vy: vy,
      rot: flakeRand() * Math.PI * 2,
      angVel: (flakeRand() - .5) * 10,
      born: 0, size: 3 + flakeRand() * 3
    });
  }
  function stepFlakes(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    var alive = [];
    for (var i = 0; i < flakes.length; i += 1) {
      var f = flakes[i];
      f.vy += FLAKE_GRAVITY_PX_S2 * dtS;
      f.x += f.vx * dtS;
      f.y += f.vy * dtS;
      f.rot += f.angVel * dtS;
      f.born += dtMs;
      if (f.born < FLAKE_LIFE_MS && f.x > -8 && f.x < FW + 8 && f.y < FH + 8) alive.push(f);
    }
    flakes = alive;

Item 03 repainted 12.3% of the frame at an average intensity of 82.1 across 21/23 measured frames.

04Reveal Radius That Spreads Outward From the Center

While the pointer holds, press.r chases WIPE_TARGET_R by WIPE_LERP_RATE every frame — a lerp toward a fixed radius rather than a linear grow — and wipe() erases a circle of that current radius each tick. clearedRatio() is checked on the same tick and opens the card past 82% without waiting for the lerp to finish settling.

Reveal Radius That Spreads Outward From the Center
Open in a new tab

04 · Reveal Radius That Spreads Outward From the Center — Measured area 23.424% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

wipeWIPE_LERP_RATEWIPE_TARGET_R
  function tick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min(ts - lastTs, 48);
    var dtS = dt / 1000;
    lastTs = ts;
    if (holding && !opened && press) {
      press.r += (WIPE_TARGET_R - press.r) * Math.min(1, WIPE_LERP_RATE * dtS);
      wipe();
      if (clearedRatio() > .82) openCard();
    }
    if (!reduce && root.classList.contains('is-demo')) {
      elapsed += dt;
      var tMod = elapsed % DEMO_LOOP_MS;
      if (tMod < dt) resetDemo();
      runDemo(tMod, dtS);
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

Item 04 repainted 23.4% of the frame at an average intensity of 69.0 across 23/23 measured frames.

05Multi-Touch Scratching Where Each Finger Erases Its Own Path

A pointers map keyed by event.pointerId is what makes a scratch card for mobile behave correctly when two fingers land at once: pointermove looks up the previous point for that specific pointer, draws a line with scratchLine() from there to the new point, then overwrites the stored position. clearedRatio() runs inside that same handler for every pointer independently.

Multi-Touch Scratching Where Each Finger Erases Its Own Path
Open in a new tab

05 · Multi-Touch Scratching Where Each Finger Erases Its Own Path — Measured area 17.315% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointersscratchLineadvance
    if (opened) return;
    root.classList.remove('is-demo');
    var p = pos(e);
    pointers.set(e.pointerId, p);
    scratchAt(p.x, p.y);
    if (zone.setPointerCapture) zone.setPointerCapture(e.pointerId);
  });
  zone.addEventListener('pointermove', function (e) {
    if (opened || !pointers.has(e.pointerId)) return;
    var prev = pointers.get(e.pointerId);
    var p = pos(e);
    scratchLine(prev.x, prev.y, p.x, p.y);
    pointers.set(e.pointerId, p);
    if (clearedRatio() > .82) openCard();
  });
  function release(e) { pointers.delete(e.pointerId); }
  window.addEventListener('pointerup', release);

Item 05 repainted 17.3% of the frame at an average intensity of 121.7 across 10/23 measured frames.

06Progress Ring That Fills in Sync With the Scratched Ratio

The scratched-pixel ratio, measured with the same formula as item 01, updates a donut progress ring — but not on every frame. A full getImageData scan is heavy enough that calling it on every ~60fps tick was measured stalling the render pipeline past frame 17 of 23, so tick() only recomputes it on an actual scratch tick or once every three ticks.

Progress Ring That Fills in Sync With the Scratched Ratio
Open in a new tab

06 · Progress Ring That Fills in Sync With the Scratched Ratio — Measured area 24.568% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

RING_SYNC_THRESHOLD_PCTdrawRingclearedPixelRatio
  function tick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min(ts - lastTs, 48);
    lastTs = ts;
    var scratched = false;
    if (!reduce && root.classList.contains('is-demo')) {
      elapsed += dt;
      var tMod = elapsed % DEMO_LOOP_MS;
      if (tMod < dt) resetDemo();
      scratched = runDemo(tMod);
    }
    // A full getImageData scan is expensive — calling it on every rAF tick (~60fps) backs up the
    // headless render pipeline until rAF itself stalls (measured: frame 17 of 23 stops it cold).
    // So it's only re-measured on an actual scratch tick or once every 3 ticks (~50ms, tighter than the 12fps capture).
    ringTick += 1;
    if (scratched || ringTick % 3 === 0) {
      var ratio = clearedPixelRatio();
      drawRing(ratio);
      // During the demo the ring/card must keep moving through the whole capture window, so auto-open never fires here —
      // only a real user scratching (which clears is-demo) past the threshold opens the card.
      if (!root.classList.contains('is-demo') && ratio * 100 >= RING_SYNC_THRESHOLD_PCT) openCard();
    }
    requestAnimationFrame(tick);

Item 06 repainted 24.6% of the frame at an average intensity of 133.3 across 19/23 measured frames.

07Confetti Burst When the Card Is Fully Revealed

Confetti fires once, guarded by a burst flag, when the cleared ratio passes 88%. From there stepConfetti() adds CONFETTI_GRAVITY to each piece's vertical velocity every tick and reverses it by -CONFETTI_BOUNCE when a piece lands past the floor, capped at three bounces before it settles.

Confetti Burst When the Card Is Fully Revealed
Open in a new tab

07 · Confetti Burst When the Card Is Fully Revealed — Measured area 14.252% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

burstConfettistepConfettiCONFETTI_BOUNCE
        vy: -180 - confettiRand() * 140,
        rot: confettiRand() * Math.PI * 2,
        angVel: (confettiRand() - .5) * 8,
        w: 5 + confettiRand() * 3,
        h: 3 + confettiRand() * 2,
        color: COLORS[i % COLORS.length],
        bounces: 0
      });
    }
  }
  function stepConfetti(dtMs) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < confetti.length; i += 1) {
      var c = confetti[i];
      c.vy += CONFETTI_GRAVITY * dtS;
      c.x += c.vx * dtS;
      c.y += c.vy * dtS;
      c.rot += c.angVel * dtS;
      if (c.y > FH - 6 && c.vy > 0 && c.bounces < 3) {
        c.y = FH - 6;
        c.vy *= -CONFETTI_BOUNCE;
        c.vx *= 0.7;
        c.bounces += 1;
      }
    }

Item 07 repainted 14.3% of the frame at an average intensity of 136.4 across 16/23 measured frames.

08Keyboard Fallback That Opens the Same Result

There's no empty handler standing in for mouse-only interaction here: keydown moves cursor.x/cursor.y by KEY_STEP on the arrow keys, and Enter or Space calls the exact same scratchAt() plus clearedRatio() path that a pointer drag would call, then opens the card past the same 82% threshold.

Keyboard Fallback That Opens the Same Result
Open in a new tab

08 · Keyboard Fallback That Opens the Same Result — Measured area 4.137% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

placeCursorKEY_STEPcursor
  // Keyboard path — no empty handler. It actually moves the cursor (state change) and blocks scroll with preventDefault.
  zone.addEventListener('keydown', function (e) {
    if (opened) return;
    var moved = false;
    if (e.key === 'ArrowUp') { cursor.y = Math.max(6, cursor.y - KEY_STEP); moved = true; }
    else if (e.key === 'ArrowDown') { cursor.y = Math.min(FH - 6, cursor.y + KEY_STEP); moved = true; }
    else if (e.key === 'ArrowLeft') { cursor.x = Math.max(6, cursor.x - KEY_STEP); moved = true; }
    else if (e.key === 'ArrowRight') { cursor.x = Math.min(FW - 6, cursor.x + KEY_STEP); moved = true; }
    else if (e.key === 'Enter' || e.key === ' ') {
      root.classList.remove('is-demo');
      scratchAt(cursor.x, cursor.y);
      var ratio = clearedRatio();
      live.textContent = Math.round(ratio * 100) + '% scratched';
      if (ratio > .82) openCard();
      e.preventDefault();
      return;
    } else { return; }
    root.classList.remove('is-demo');
    placeCursor();
    e.preventDefault();
  });

Item 08 repainted 4.1% of the frame at an average intensity of 84.9 across 15/23 measured frames.

09Hero Scene Arranging Several Cards on One Screen

makeCard() stamps out several cards that each run item 01's pixel calc and item 03's flake physics independently, and once a card's ratio crosses HERO_REVEAL_THRESHOLD_PCT, burstConfetti() reuses item 07's gravity-and-bounce step through the shared stepParticles() loop across the whole scene.

Hero Scene Arranging Several Cards on One Screen
Open in a new tab

09 · Hero Scene Arranging Several Cards on One Screen — Measured area 15.311% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

makeCardHERO_REVEAL_THRESHOLD_PCTstepParticles
    function burstConfetti() {
      for (var i = 0; i < HERO_CONFETTI_COUNT; i += 1) {
        particles.push({
          kind: 'confetti', x: FW / 2, y: FH * 0.3,
          vx: (fxRand() - .5) * 160, vy: -140 - fxRand() * 100,
          rot: fxRand() * Math.PI * 2, angVel: (fxRand() - .5) * 7,
          born: 0, size: 4 + fxRand() * 2, color: COLORS[i % COLORS.length], bounces: 0
        });
      }
    }
    function stepParticles(dtMs) {
      var dtS = dtMs / 1000;
      var alive = [];
      for (var i = 0; i < particles.length; i += 1) {
        var p = particles[i];
        p.vy += HERO_FLAKE_GRAVITY_PX_S2 * dtS;
        p.x += p.vx * dtS;
        p.y += p.vy * dtS;
        p.rot += p.angVel * dtS;
        p.born += dtMs;
        if (p.kind === 'confetti' && p.y > FH - 4 && p.vy > 0 && p.bounces < 2) {
          p.y = FH - 4; p.vy *= -0.4; p.bounces += 1;
        }

Item 09 repainted 15.3% of the frame at an average intensity of 107.0 across 17/23 measured frames.

Nine Scratch Card Reveals, Measured in Area and Intensity

The same 24-frame capture window that timed every demo above also gives the nine builds a common yardstick — cumulative repainted area, peak area in a single frame, average intensity of the pixels that moved, and how many of the 23 measured deltas actually showed motion:

# Pattern Area Peak area Avg intensity Moved frames
01 Pixel-threshold auto-peel 12.1% 1.2% 40.4 15/23
02 Foil noise texture 28.6% 9.5% 37.2 17/23
03 Flake gravity particles 12.3% 1.7% 82.1 21/23
04 Radial wipe lerp 23.4% 19.4% 69.0 23/23
05 Multi-touch pointer 17.3% 4.9% 121.7 10/23
06 Progress ring sync 24.6% 1.8% 133.3 19/23
07 Prize confetti burst 14.3% 1.2% 136.4 16/23
08 Keyboard fallback 4.1% 0.8% 84.9 15/23
09 Hero multi-card scene 15.3% 2.1% 107.0 17/23

Item 08 clears the least area of the nine not because its calculation differs — it runs the same cleared-pixel formula as items 01, 04, and 05 (clearedPixelRatio() in 01, clearedRatio() in 04, 05, and 08) — but because each keydown moves the cursor a fixed KEY_STEP rather than tracing a free-form drag, so the same capture window sees fewer pixels change.

Where the Value Changes the Reveal — Peel Threshold, Flake Gravity, Confetti Count

Variant Value Feel
Adjust the peel threshold 01 SCRATCH_REVEAL_THRESHOLD_PCT 50 → 80 Lowering the value auto-peels the rest after only a little scratching, while raising it demands far more scratching before the auto-peel fires.
Adjust the flake gravity acceleration 03 FLAKE_GRAVITY_PX_S2 400 → 1200 Lowering the value makes flakes drift slowly and hang in the air, while raising it drops them fast so they vanish in an instant.
Adjust the confetti count 07 CONFETTI_COUNT 24 → 60 Lowering the value scatters only a few sparse confetti pieces, while raising it bursts enough to fill the whole screen densely.

None of these three values touch layout or the gravity-step math itself — they only move where the same calculation crosses its own threshold, which is why the row above reads as a feel change rather than a rebuild.

Where It Breaks — Reduced Motion, Rapid Consecutive Scratching, Frame Drops on Low-End Devices

All nine files check prefers-reduced-motion once, at startup, and stop their own idle demo loop the same way (if (reduce) root.classList.remove('is-demo') plus a !reduce guard before runDemo()); items 03, 07, and 09 go one step further and also skip stepFlakes()/stepConfetti()/stepParticles() under that same flag, so their loose particles never spawn at all rather than just not auto-playing.

Rapid, consecutive scratching is where these nine builds diverge most. Items 01, 04, 05, and 08 all recompute the cleared-pixel ratio straight from inside their pointer or key handler on every single move event with no throttle — clearedPixelRatio() in item 01, clearedRatio() in 04, 05, and 08 — fine at a mouse's event rate, but a fast multi-touch scribble on item 05 fires two independent pointermove streams, each running its own full-canvas getImageData scan. Item 06 is the only one of the nine that throttles this call on purpose, after its own unthrottled version was measured stalling requestAnimationFrame past frame 17 of 23 — the comment inside its tick() (see item 06 above) records that exact failure. On a low-end device, the same per-move getImageData cost is the first thing that drops frames, well before the flake or confetti physics get expensive. The same tradeoff between a full-canvas pixel scan and frame budget shows up in a different nine builds in HTML5 Canvas Animation: 9 Warp & Droplet Effects.

27qe6aea sits inside the nine index.html/style.scss pairs behind these builds, and reading them side by side is the fastest way to see why item 06 needed a throttle that items 01, 04, 05, and 08 don't have.

Three Variants and Accessibility

Each variant above only edits one named constant — SCRATCH_REVEAL_THRESHOLD_PCT, FLAKE_GRAVITY_PX_S2, or CONFETTI_COUNT — and every other line of the pattern it belongs to stays untouched, so the table doubles as a map of exactly where to change the feel of a scratch card game without touching its pixel math.

Item 08's keyboard path is real keyboard support, not a fallback in name only: arrow keys move a visible cursor, Enter/Space scratch at that cursor's position through the same clearedRatio() call every pointer-driven item uses, and e.preventDefault() on each key stops the page from scrolling underneath. For the reduced-motion behavior described above, MDN's prefers-reduced-motion reference and MDN's Canvas getImageData() reference cover the two browser APIs these nine builds lean on hardest.

What's the Difference Between a Scratch Card Reveal and Coupon Design?

Coupon Design: 9 CSS Coupons, Stamp to Spin is about the coupon's resting shape — a stamp, a ticket edge, a spinning wheel — built mostly with CSS and settled the moment a class toggles. A scratch card reveal gift works the opposite way: nothing about the card's look changes on a class toggle, because the "reveal" is a canvas pixel count crossing a threshold in real time, one erased pixel at a time. If a project just needs a coupon to look a certain way once it's active, reach for the coupon set; if the interaction itself — the erasing — is the point, these nine patterns are the ones built for that.

Questions Worth Answering Before You Scratch One of These Nine

Can any of these nine patterns be dropped onto a page without a build step?

Yes — each of the nine is a single self-contained index.html plus style.scss, with no external library or CDN script in any of the nine <script> tags. Compiling the Sass and pasting the markup is the only step.

Is a scratch card game like these safe to reuse for a real promotion without adding a physics library?

The gravity, bounce, and lerp math across all nine items is plain arithmetic inside requestAnimationFrame ticks — no Math.random() anywhere in any of the nine files, only a seeded seedRand() generator, which is what keeps the same card reproducible across a reload if a seed is fixed.

Why does item 06 recompute its progress ring only every third tick instead of every frame?

Because getImageData() scans the entire canvas's pixels, and item 06's own comment records the unthrottled version stalling requestAnimationFrame past frame 17 of its 23 measured frames — the throttle in the code fence above exists to keep the ring animation itself alive.

Enter the archive password

The password is inside this article. You will find it as you read.