# Scratch card

> A gift card that you scratch with a pointer or a finger to reveal a glowing $299 prize.

A gift card for Delorence with a dotted face. Hover it and it grows under a glow of green, teal and blue that follows the pointer and tints the frame. Drag across it to reveal a $299 prize through a pixel-stepped scratch. Your progress stays when you leave the card; Reveal and Cover again work from the keyboard too.

A React component by Bruno Delorence: https://brunodelorence.com/components/scratch-card. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Installation

Install it with the shadcn CLI, into components/scratch-card of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/scratch-card.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { ScratchCard } from './scratch-card';

export function Gift() {
  return (
    <ScratchCard
      autoplay={false}
      name="Delorence"
      plan="PRO"
      period="8760H"
      generated="2/26/2026 10 PM"
      onReveal={() => console.log('revealed')}
    />
  );
}
```

## How it works

- The pixel mask follows a 64 by 32 grid. The brush joins every pair of pointer positions, including coalesced samples, so quick drags reveal a continuous path while keeping the stepped edges.
- The prize, lit from below, is painted once on a canvas kept aside, with transparency between the black isometric shapes. The dotted face and its glow stay visible through these gaps; there is no opaque image covering them. The visible canvas is capped at twice the screen’s density.
- After 55% of the face has been scratched, the remaining cells dissolve in a random order. A reader’s progress stays after releasing the pointer, moving away or scrolling. Cover again resets it immediately; the idle demo gives up control after a real press.
- Once the card is fully revealed, an SVG of the same prize takes the canvas’s place and the canvas gives its pixels back. It gives them back too when explicitly covered, or when an untouched demo scrolls off screen.
- The glow is one fixed box on the face and one on the frame, both drifting at nearly the pointer’s pace by their transform. The card grows and tilts on hover; its current transform is frozen while scratching to keep the brush aligned with the pointer.
- The card, its dotted face, its labels, its glow and the stage are light-dark() tokens on the root, so it follows the page’s color-scheme, or the colorScheme prop: a white card with a silver face under a pastel glow by day, a near-black one at night. The revealed backing follows the card’s colour in both the canvas and SVG.

## API reference

- **name** (`string`, default `'Delorence'`): Who the card is for, at its top right.
- **plan** (`string`, default `'PRO'`): The plan, at its bottom left.
- **period** (`string`, default `'8760H'`): The period, under the plan.
- **generated** (`string`, default `'2/26/2026 10 PM'`): When the card was made, at its bottom right.
- **onReveal** (`() => void`): Called when the reader has revealed the card.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers the card and scratches it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- A real Reveal button, which becomes Cover again, gives keyboard and switch users the same result as scratching.
- The scratch panel is an image whose label says what to do, and names the prize once it is revealed. Revealing and covering are announced in a polite live region.
- Only a drag that starts on the scratch panel is captured (touch-action: none there); drags elsewhere scroll the page.
- Under reduced motion the reveal and cover happen at once and the demo never plays. Keyboard focus on the stage stops the demo.

## Source

### scratch-card/scratch-card.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useId, useRef, useState, type FocusEvent, type PointerEvent } from 'react';
import { FACE, Mark, Prize } from './art';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { createScratch, type Scratch } from './scratch';
import { useAutoplay } from './use-autoplay';
import styles from './scratch-card.module.css';

/*
 * Scratch card: a gift card whose dotted face hides a prize. Scratch it with a pointer or a
 * finger and the price shows through, with the black isometric cards behind it; once enough is
 * scratched, the rest dissolves away.
 *
 * - The cover is the face itself, printed with dots and lit, under the pointer, by a glow of
 *   green, teal and blue that drifts at nearly the pointer's pace and tints the frame around the
 *   face. The transparent prize lies on a canvas over it, seen through the pixel mask
 *   (scratch.ts). Joined strokes reveal the artwork without covering the printed dots or glow.
 * - Past 55% revealed, the remaining cells dissolve away.
 *   Fully revealed, an SVG of the same prize takes the canvas's place and the canvas gives its
 *   pixels back; it takes them again only to be scratched or to dissolve.
 * - Under a mouse the card grows and tilts toward the pointer, by one transform. When the pointer
 *   leaves, the glow fades and the reader's progress stays. Touch scratches the same way
 *   (the face takes touch-action: none, so a drag on it never scrolls). During a press the card's
 *   transform is frozen, keeping the scratch coordinates aligned with the finger or pointer.
 * - A button reveals the card from the keyboard, and covers it again.
 * - While on screen and untouched, a demo pointer hovers the card and scratches it in strokes.
 * - Light or dark: the stage, the card, its face, the glow and the button take light-dark() colours
 *   from the stylesheet, so they follow the page's color-scheme, or the one the colorScheme prop
 *   sets: a white card with a silver face and a pastel glow by day, a black one with a dark face
 *   and a deep glow at night. The prize stays transparent between its black shapes.
 */

/** Revealed past this share of the face, the rest dissolves. */
const ENOUGH = 0.55;
/** The dissolves, in ms: the rest of the cover going, and all of it coming back. */
const REVEAL_MS = 520;
const COVER_MS = 700;
/** How long the cover waits, after the pointer leaves, before it comes back, in ms. */
const RECOVER_AFTER = 1400;
/** The tilt toward the pointer, in degrees, at the card's edges. */
const TILT = { x: 3.5, y: 4.5 };

type Phase = 'covered' | 'partial' | 'revealed';

/** The demo's strokes, as shares of the face: from, to. Each runs down to the left. */
const STROKES: readonly [Point, Point][] = [
  [
    { x: 0.86, y: 0.2 },
    { x: 0.22, y: 0.78 },
  ],
  [
    { x: 0.62, y: 0.1 },
    { x: 0.1, y: 0.6 },
  ],
  [
    { x: 0.95, y: 0.48 },
    { x: 0.44, y: 0.92 },
  ],
  [
    { x: 0.5, y: 0.34 },
    { x: 0.14, y: 0.9 },
  ],
];

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function ScratchCard({
  className = '',
  autoplay = true,
  name = 'Delorence',
  plan = 'PRO',
  period = '8760H',
  generated = '2/26/2026 10 PM',
  onReveal,
  colorScheme,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** While on screen and untouched, a demo pointer hovers the card and scratches it. */
  autoplay?: boolean;
  /** Who the card is for, at its top right. */
  name?: string;
  /** The plan, at its bottom left. */
  plan?: string;
  /** The period, under the plan. */
  period?: string;
  /** When the card was made, at its bottom right. */
  generated?: string;
  /** Called when the reader has revealed the card. */
  onReveal?: () => void;
  /** Its colours: light, dark, or 'system' for the reader's setting; unset, the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const ids = `sc${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
  const [phase, setPhase] = useState<Phase>('covered');
  /** The SVG of the prize shows, in place of the canvas, once the prize is all revealed. */
  const [standIn, setStandIn] = useState(false);
  const [lit, setLit] = useState(false);
  const [message, setMessage] = useState('');
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  /** A reader's press takes over until the demo is explicitly restarted. */
  const [used, setUsed] = useState(false);

  const card = useRef<HTMLDivElement>(null);
  const face = useRef<HTMLDivElement>(null);
  const light = useRef<HTMLDivElement>(null);
  const frameLight = useRef<HTMLSpanElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const scratch = useRef<Scratch | null>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  const recover = useRef(0);
  const dim = useRef(0);
  /** The press scratching the face: its pointer, and where it last was, in face units. */
  const press = useRef<{ id: number; at: Point } | null>(null);
  /** The card's tilt toward the pointer, in degrees, and how much it is grown. */
  const pose = useRef({ x: 0, y: 0, scale: 1 });
  // The phase as the handlers see it between renders.
  const phaseNow = useRef<Phase>('covered');

  const enter = (next: Phase) => {
    phaseNow.current = next;
    setPhase(next);
  };

  // The scratch, on the canvas; the face sizes it, at up to twice the screen's density.
  useEffect(() => {
    const element = canvas.current;
    if (!element) return;
    const made = createScratch(element, () =>
      Math.min(2, window.devicePixelRatio * pose.current.scale),
    );
    scratch.current = made;
    // A partial scratch follows responsive sizing without losing its mask.
    const refresh = () => {
      if (element.width && element.height) made.redraw();
    };
    const size = new ResizeObserver(refresh);
    size.observe(element);
    return () => {
      size.disconnect();
      made.dispose();
      scratch.current = null;
      window.clearTimeout(recover.current);
      window.clearTimeout(dim.current);
    };
  }, []);

  /** A point on the screen, in face units and as shares of the face. */
  const local = (clientX: number, clientY: number) => {
    const box = face.current?.getBoundingClientRect();
    const share = box
      ? { x: (clientX - box.left) / box.width, y: (clientY - box.top) / box.height }
      : { x: 0.5, y: 0.5 };
    return { share, at: { x: share.x * FACE.width, y: share.y * FACE.height } };
  };

  /** Poses the card: tilted toward the pointer, grown under a mouse. CSS eases it there. */
  const place = () => {
    const { x, y, scale } = pose.current;
    const element = card.current;
    if (element) {
      element.style.transform = `perspective(1200px) rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg) scale(${scale})`;
    }
  };

  /** Moves the glow (at most of the pointer's pace) and, unless pressing, tilts the card. */
  const follow = (share: Point) => {
    // How far the glow moves, in shares of the face; both lights are 1.6 faces wide.
    const x = ((share.x - 0.5) * 0.82 * 100) / 1.6;
    const y = (share.y - 0.5) * 0.2 * 100;
    const glow = light.current;
    if (glow) glow.style.transform = `translate(${x.toFixed(2)}%, ${(y / 1.2).toFixed(2)}%)`;
    const tint = frameLight.current;
    if (tint) tint.style.transform = `translateX(${x.toFixed(2)}%)`;
    const tilt = press.current ? 0 : 1;
    pose.current.x = (0.5 - share.y) * 2 * TILT.x * tilt;
    pose.current.y = (share.x - 0.5) * 2 * TILT.y * tilt;
    if (!press.current) place();
  };

  /** Clears the rest of the cover, then hands the canvas over to the SVG. */
  const finish = (byReader: boolean) => {
    const made = scratch.current;
    if (!made || phaseNow.current === 'revealed') return;
    enter('revealed');
    made.dissolve(false, reducedMotion() ? 0 : REVEAL_MS, () => setStandIn(true));
    if (byReader) {
      onReveal?.();
      setMessage('Revealed: a $299 gift.');
    }
  };

  /** Brings the cover back, dissolving it in. */
  const cover = (byReader: boolean) => {
    const made = scratch.current;
    window.clearTimeout(recover.current);
    if (!made || phaseNow.current === 'covered') return;
    enter('covered');
    if (byReader) {
      // An explicit reset is ready for a new stroke immediately, with no old coverage left.
      made.fill(false);
      made.release();
      setStandIn(false);
      setMessage('Covered again.');
      return;
    }
    // The canvas shows the prize again before the SVG goes, then the cover dissolves in.
    made.redraw();
    setStandIn(false);
    made.dissolve(true, reducedMotion() ? 0 : COVER_MS, () => made.release());
  };

  const scratchTo = (at: Point, byReader: boolean) => {
    const made = scratch.current;
    const current = press.current;
    if (!made || !current || phaseNow.current === 'revealed') return;
    made.stroke(current.at, at);
    current.at = at;
    if (phaseNow.current === 'covered') enter('partial');
    if (made.revealed() >= ENOUGH) finish(byReader);
  };

  /** The glow comes up, and the card grows under a mouse. */
  const lightUp = (grow: boolean) => {
    window.clearTimeout(dim.current);
    window.clearTimeout(recover.current);
    setLit(true);
    scratch.current?.setLit(true);
    if (grow) {
      // How much it grows is the stylesheet's, smaller on a narrow stage.
      const scene = card.current?.closest(`.${styles.scene}`);
      pose.current.scale = scene
        ? Number(getComputedStyle(scene).getPropertyValue('--lift')) || 1
        : 1;
      place();
    }
  };

  /** The glow fades and the card settles; the cover comes back a moment later, if asked. */
  const lightDown = (recoverToo: boolean) => {
    setLit(false);
    scratch.current?.setLit(false);
    pose.current = { x: 0, y: 0, scale: 1 };
    place();
    if (recoverToo) recover.current = window.setTimeout(() => cover(false), RECOVER_AFTER);
  };

  const begin = (id: number, clientX: number, clientY: number, byReader: boolean) => {
    const { share, at } = local(clientX, clientY);
    press.current = { id, at };
    follow(share);
    scratchTo(at, byReader);
  };

  const onPointerEnter = (event: PointerEvent<HTMLDivElement>) => {
    if (event.pointerType !== 'mouse') return;
    lightUp(true);
    follow(local(event.clientX, event.clientY).share);
  };
  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const { share, at } = local(event.clientX, event.clientY);
    if (event.pointerType === 'mouse' || press.current) follow(share);
    if (press.current?.id !== event.pointerId) return;
    // Browsers may merge several pen/touch/mouse samples into one event. Keep all of them,
    // joining each pair so quick movements do not leave holes in the scratch.
    const samples = event.nativeEvent.getCoalescedEvents?.() ?? [];
    for (const sample of samples) scratchTo(local(sample.clientX, sample.clientY).at, true);
    scratchTo(at, true);
  };
  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || press.current) return;
    if (phaseNow.current === 'revealed') {
      if (used) return;
      // The first real scratch can take over even if the idle demo has revealed its prize.
      cover(true);
    }
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    lightUp(event.pointerType === 'mouse');
    // Freeze the actual animated pose, rather than jumping to its hover target mid-stroke.
    const element = card.current;
    if (element) {
      const transform = getComputedStyle(element).transform;
      element.dataset.scratching = '';
      element.style.transform = transform;
    }
    event.currentTarget.dataset.scratching = '';
    begin(event.pointerId, event.clientX, event.clientY, true);
  };
  const end = (event: PointerEvent<HTMLDivElement>) => {
    if (press.current?.id !== event.pointerId) return;
    if (event.type === 'pointerup') scratchTo(local(event.clientX, event.clientY).at, true);
    press.current = null;
    delete event.currentTarget.dataset.scratching;
    if (card.current) delete card.current.dataset.scratching;
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    if (event.pointerType === 'mouse') follow(local(event.clientX, event.clientY).share);
    // A finger leaves no hover: the glow fades, what it revealed stays.
    if (event.pointerType !== 'mouse') dim.current = window.setTimeout(() => lightDown(false), 600);
  };
  const onPointerLeave = (event: PointerEvent<HTMLDivElement>) => {
    if (event.pointerType !== 'mouse' || press.current) return;
    lightDown(false);
  };

  /** Hover pauses the demo; a real press takes over until the demo is restarted. */
  const readerActs = () => {
    stopDemo.current?.();
    stopDemo.current = null;
    window.clearTimeout(recover.current);
    take();
  };

  // Once the SVG shows the prize, the canvas gives its pixels back.
  useEffect(() => {
    if (standIn) scratch.current?.release();
  }, [standIn]);

  // Off screen, an untouched demo resets and releases its canvas; a reader keeps their progress.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting || used || phaseNow.current === 'covered') return;
      window.clearTimeout(recover.current);
      scratch.current?.fill(false);
      scratch.current?.release();
      press.current = null;
      enter('covered');
      setStandIn(false);
      lightDown(false);
    });
    observer.observe(element);
    return () => observer.disconnect();
    // The observer reads the latest state through refs.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [root, used]);

  const demo = autoplay && playing && !keyboard && !used;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const area = face.current;
    if (!demo || !stage || !arrow || !area) return;
    /** A share of the face, on the screen. */
    const onScreen = (share: Point): Point => {
      const box = area.getBoundingClientRect();
      return { x: box.left + box.width * share.x, y: box.top + box.height * share.y };
    };
    const stop = runDemo(
      stage,
      arrow,
      async ({ wait, moveToPoint, press: dip, drag }) => {
        await wait(900);
        for (;;) {
          // Onto the card: it grows, the glow comes up under the pointer.
          if (phaseNow.current !== 'covered') {
            cover(false);
            await wait(COVER_MS + 200);
          }
          await moveToPoint(onScreen({ x: 0.22, y: 0.62 }));
          lightUp(true);
          follow({ x: 0.22, y: 0.62 });
          await wait(300);
          // Across the card, the glow going with the pointer.
          follow({ x: 0.84, y: 0.36 });
          await moveToPoint(onScreen({ x: 0.84, y: 0.36 }));
          await wait(250);
          // Strokes, until the rest dissolves.
          for (const [from, to] of STROKES) {
            if (phaseNow.current === 'revealed') break;
            await moveToPoint(onScreen(from));
            await dip(() => {
              const point = onScreen(from);
              begin(-1, point.x, point.y, false);
            });
            const start = onScreen(from);
            const target = onScreen(to);
            await drag(target, 520, (share) => {
              const x = start.x + (target.x - start.x) * share;
              const y = start.y + (target.y - start.y) * share;
              const { share: on, at } = local(x, y);
              follow(on);
              scratchTo(at, false);
            });
            press.current = null;
            await wait(120);
          }
          await wait(1100);
          // Away: the card settles and, a moment later, covers itself again.
          await moveToPoint({
            x: stage.getBoundingClientRect().right - 40,
            y: stage.getBoundingClientRect().bottom - 30,
          });
          lightDown(true);
          await wait(RECOVER_AFTER + COVER_MS + 1600);
        }
      },
      () => {
        if (press.current?.id === -1) press.current = null;
        // A mouse that took over on the card keeps it lit.
        if (!area.matches(':hover')) lightDown(false);
      },
    );
    stopDemo.current = stop;
    return stop;
    // The script reads the latest state through refs; it restarts only when the demo does.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  const revealed = phase === 'revealed';
  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={() => {
        readerActs();
        setUsed(true);
      }}
      onPointerMove={(event) => {
        if (event.pointerType === 'mouse') readerActs();
      }}
      onKeyDownCapture={() => {
        readerActs();
        setUsed(true);
      }}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.scene}>
        <div className={styles.holder}>
          <div
            ref={card}
            className={styles.card}
            data-lit={lit || undefined}
            role="group"
            aria-label={`Gift card for ${name}`}
          >
            <Mark className={styles.mark} />
            <span className={styles.name}>For {name}</span>
            <span ref={frameLight} className={styles.frameLight} aria-hidden="true" />
            <div
              ref={face}
              className={styles.face}
              data-lit={lit || undefined}
              role="img"
              aria-label={
                revealed
                  ? 'Revealed: a $299 gift.'
                  : 'A scratch-off panel. Scratch it, or use the Reveal button.'
              }
              onPointerEnter={onPointerEnter}
              onPointerMove={onPointerMove}
              onPointerDown={onPointerDown}
              onPointerUp={end}
              onPointerCancel={end}
              onLostPointerCapture={end}
              onPointerLeave={onPointerLeave}
            >
              <div ref={light} className={styles.light} />
              <div className={styles.dots} />
              <div className={styles.prize} data-shown={standIn || undefined}>
                <span className={styles.prizeDim}>
                  <Prize lit={false} ids={ids} />
                </span>
                <span className={styles.prizeLit}>
                  <Prize lit ids={ids} />
                </span>
              </div>
              <canvas ref={canvas} className={styles.canvas} />
            </div>
            <div className={styles.labels}>
              <p>
                Plan / {plan}
                <br />
                Period / {period}
              </p>
              <p>
                Generated
                <br />
                {generated}
              </p>
            </div>
          </div>
        </div>
        <button
          type="button"
          className={styles.action}
          onClick={() => (revealed ? cover(true) : finish(true))}
        >
          {revealed ? 'Cover again' : 'Reveal'}
        </button>
        <p className={styles.hidden} aria-live="polite">
          {message}
        </p>
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
    </div>
  );
}
```

### scratch-card/scratch-card.module.css

```css
/* Scratch card (scratch-card.tsx): a gift card on a stage, a button under it. By day a white card
   with a silver face on a white stage; at night a black card with a dark face, and a hairline, on
   a dark grey stage a step above it.

   Colours are the tokens below, each light-dark(light, dark): the component takes the
   color-scheme it inherits from the page (light unless the page says otherwise), or the one its
   colorScheme prop sets (data-color-scheme). The transparent prize leaves the dotted
   face and its glow visible between the pixel-shaped artwork in both themes.

   Every size of the card is a multiple of --u, its width over 200; the stage is a size container
   and the card's width follows it. The scene is one layout boundary of a size computed from that
   width, room left for the card grown under a mouse.

   Memory. The card grows and tilts by one transform, on a layer of its own. The glow under the
   pointer is one fixed-size box that moves by its transform. The canvas is the face's size, at up
   to twice the screen's density, and only while it is scratched or dissolving. */

.stage {
  /* The stage and its edge. */
  --surface: #fff;
  --edge: rgb(0 0 0 / 0.06);
  /* The card: its frame, its hairline, its near shade and its drop; its mark, and its name. */
  --card: #fbfbfc;
  --card-edge: rgb(0 0 0 / 0.08);
  --shadow-near: rgb(0 0 0 / 0.07);
  --shadow: rgb(0 0 0 / 0.12);
  --card-mark: #18181b;
  --card-name: #5d5d64;
  /* The face, the cover that is scratched: its fill, its printed dots, and the labels on it. */
  --face: #e4e4e8;
  --dot: rgb(0 0 0 / 0.17);
  --label: #3f3f46;
  /* The glow under the pointer, across: its shade along the top (and where that clears), then
     its colours, from clear to green, teal, cyan and blue, and clear again. */
  --glow-shade: rgb(255 255 255 / 0.45);
  --glow-shade-clear: rgb(255 255 255 / 0);
  --glow-in: rgb(228 228 232 / 0);
  --glow-green-soft: rgb(46 196 126 / 0.3);
  --glow-green: rgb(46 200 126 / 0.55);
  --glow-teal: rgb(32 190 176 / 0.55);
  --glow-cyan: rgb(30 166 214 / 0.52);
  --glow-blue: rgb(48 122 196 / 0.4);
  --glow-out: rgb(48 122 196 / 0);
  /* The button: its face, frame and label; its face and frame under the pointer; focus rings. */
  --surface-2: #fff;
  --line: #e4e4e7;
  --ink: #3f3f46;
  --hover: #fafafa;
  --line-hover: #d4d4d8;
  --focus: #0a84ff;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#fff, #161618);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --card: light-dark(#fbfbfc, #0d0d0d);
    --card-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --shadow-near: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.4));
    --shadow: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.55));
    --card-mark: light-dark(#18181b, #fff);
    --card-name: light-dark(#5d5d64, #6b6b6b);
    --face: light-dark(#e4e4e8, #232323);
    --dot: light-dark(rgb(0 0 0 / 0.17), rgb(255 255 255 / 0.3));
    --label: light-dark(#3f3f46, #cbcbcb);
    --glow-shade: light-dark(rgb(255 255 255 / 0.45), rgb(24 30 34 / 0.7));
    --glow-shade-clear: light-dark(rgb(255 255 255 / 0), rgb(24 30 34 / 0));
    --glow-in: light-dark(rgb(228 228 232 / 0), rgb(38 38 38 / 0));
    --glow-green-soft: light-dark(rgb(46 196 126 / 0.3), rgb(8 104 66 / 0.75));
    --glow-green: light-dark(rgb(46 200 126 / 0.55), #0b9a5c);
    --glow-teal: light-dark(rgb(32 190 176 / 0.55), #10918a);
    --glow-cyan: light-dark(rgb(30 166 214 / 0.52), #0a7ea2);
    --glow-blue: light-dark(rgb(48 122 196 / 0.4), #155b82);
    --glow-out: light-dark(rgb(48 122 196 / 0), rgb(21 70 110 / 0));
    --surface-2: light-dark(#fff, #222225);
    --line: light-dark(#e4e4e7, #36363a);
    --ink: light-dark(#3f3f46, #d4d4d8);
    --hover: light-dark(#fafafa, #2a2a2e);
    --line-hover: light-dark(#d4d4d8, #46464b);
    --focus: light-dark(#0a84ff, #3594ff);
  }
}
/* The colorScheme prop. Without it nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}

.scene {
  /* The card's width, how much it grows under a mouse, and the room around it. */
  --w: min(40cqi, 340px);
  --lift: 1.36;
  --pad: clamp(16px, 6.2cqi, 56px);
  --u: calc(var(--w) / 200);
  --grown: calc(var(--w) * 0.625 * var(--lift));
  position: relative;
  height: calc(var(--grown) + 2 * var(--pad) + 46px);
  contain: size layout;
}
.holder {
  position: absolute;
  top: var(--pad);
  right: 0;
  left: 0;
  display: grid;
  place-items: center;
  height: var(--grown);
}

/* The card: a frame, its mark and its name, the face, the labels printed on the face. */
.card {
  position: relative;
  width: var(--w);
  height: calc(var(--w) * 0.625);
  border-radius: calc(7.7 * var(--u));
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 calc(1 * var(--u)) calc(2 * var(--u)) var(--shadow-near),
    0 calc(8 * var(--u)) calc(20 * var(--u)) var(--shadow);
  overflow: hidden;
  transform: perspective(1200px) rotateX(0deg) rotateY(0deg) scale(1);
  transition: transform 0.85s cubic-bezier(0.25, 0.1, 0.25, 1);
}
.mark {
  position: absolute;
  top: calc(8.6 * var(--u));
  left: calc(7.4 * var(--u));
  display: block;
  width: calc(7.2 * var(--u));
  height: calc(7.2 * var(--u));
  color: var(--card-mark);
}
.name {
  position: absolute;
  top: calc(8.8 * var(--u));
  right: calc(7.7 * var(--u));
  color: var(--card-name);
  font-size: calc(6.1 * var(--u));
  font-weight: 500;
  line-height: calc(7 * var(--u));
  white-space: nowrap;
}

.face {
  position: absolute;
  top: calc(23.3 * var(--u));
  left: calc(7.7 * var(--u));
  width: calc(184.6 * var(--u));
  height: calc(94.5 * var(--u));
  overflow: hidden;
  isolation: isolate;
  border-radius: calc(3 * var(--u));
  background: var(--face);
  cursor: grab;
  /* A drag that starts on the face scratches it, and never scrolls the page. */
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
.face[data-scratching] {
  cursor: grabbing;
}
.card[data-scratching] {
  transition: none;
}
.canvas {
  pointer-events: none;
}

/* The glow: a map of colours, green to the left of the pointer and blue under and right of it,
   that drifts at nearly its pace. It fades in while the card is lit, and tints the frame too. */
.light,
.frameLight {
  position: absolute;
  opacity: 0;
  transition:
    opacity 0.45s ease,
    transform 0.6s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.light {
  top: -10%;
  left: -37.4%;
  width: 160%;
  height: 120%;
  background:
    linear-gradient(var(--glow-shade), var(--glow-shade-clear) 38%),
    linear-gradient(
      90deg,
      var(--glow-in) 22%,
      var(--glow-green-soft) 31%,
      var(--glow-green) 40%,
      var(--glow-teal) 51%,
      var(--glow-cyan) 57.5%,
      var(--glow-blue) 67%,
      var(--glow-out) 77%
    );
}
.face[data-lit] .light {
  opacity: 1;
}
/* The same light, dimmer, on the card around the face: it tints the frame and the name. */
.frameLight {
  top: 0;
  bottom: 0;
  left: calc((7.7 - 0.374 * 184.6) * var(--u));
  width: calc(1.6 * 184.6 * var(--u));
  background: linear-gradient(
    90deg,
    rgb(14 143 134 / 0) 22%,
    rgb(14 150 110 / 0.75) 36%,
    #10a28a 50%,
    #0d8ea6 60%,
    rgb(21 110 150 / 0.6) 70%,
    rgb(21 70 110 / 0) 80%
  );
  pointer-events: none;
}
.card[data-lit] .frameLight {
  opacity: 0.22;
}

.canvas,
.prize,
.prize > span,
.prize svg {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
.prize {
  visibility: hidden;
}
.prize[data-shown] {
  visibility: visible;
}
.prizeLit {
  opacity: 0;
  transition: opacity 0.45s ease;
}
.face[data-lit] .prizeLit {
  opacity: 1;
}

/* The printed dots: a staggered grid, two cells across and one cell down apart. */
.dots {
  position: absolute;
  inset: 0;
  background-image:
    radial-gradient(circle, var(--dot) calc(0.6 * var(--u)), transparent calc(0.95 * var(--u))),
    radial-gradient(circle, var(--dot) calc(0.6 * var(--u)), transparent calc(0.95 * var(--u)));
  background-position:
    0 0,
    calc(2.884 * var(--u)) calc(2.953 * var(--u));
  background-size: calc(5.769 * var(--u)) calc(5.906 * var(--u));
  pointer-events: none;
}

.labels {
  position: absolute;
  top: calc(100.9 * var(--u));
  right: calc(11.6 * var(--u));
  left: calc(12 * var(--u));
  display: flex;
  justify-content: space-between;
  color: var(--label);
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, Menlo, monospace;
  font-size: calc(4.8 * var(--u));
  font-weight: 700;
  line-height: calc(6.7 * var(--u));
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  pointer-events: none;
}
.labels p {
  margin: 0;
}
.labels p + p {
  text-align: right;
}

/* Reveal, or cover again: under the card, for keys and for fingers. */
.action {
  position: absolute;
  bottom: calc(var(--pad) * 0.7);
  left: 50%;
  display: inline-flex;
  align-items: center;
  height: 30px;
  margin: 0;
  padding: 0 14px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  cursor: pointer;
  transform: translateX(-50%);
  transition:
    border-color 0.2s ease,
    background-color 0.2s ease;
}
@media (hover: hover) {
  .action:hover {
    border-color: var(--line-hover);
    background: var(--hover);
  }
}
.action:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Narrow: a larger card, which grows less. */
@container (max-width: 599px) {
  .scene {
    --w: min(80cqi, 340px);
    --lift: 1.08;
  }
}

/* The demo's pointer: an arrow, or a closed hand while it scratches. It moves over the whole
   stage, in a box of its own, contained, so that nothing in it can ever lay out the page. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}
.pointer [data-glyph='hand'],
.pointer[data-grab] [data-glyph='arrow'] {
  visibility: hidden;
}
.pointer[data-grab] [data-glyph='hand'] {
  visibility: visible;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .card,
  .light,
  .frameLight,
  .prizeLit,
  .action {
    transition: none;
  }
}
```

### scratch-card/scratch.ts

```tsx
import {
  CELL,
  FACE,
  GRID,
  PRICE,
  PRICE_DIM,
  PRICE_FONT,
  PRICE_LIT,
  PRICE_SHADE,
  SLAB_COLOR,
  SLAB_RUNS,
} from './art';

/*
 * The scratch, on a canvas. What lies under the cover (art.tsx) is painted once on a canvas kept
 * aside; the visible canvas shows it through a mask of one pixel per cell, scaled up without
 * smoothing, so what is revealed steps along the cells. A stroke clears every cell within a
 * brush's reach of its path; a dissolve clears (or covers again) the rest a few cells at a time,
 * in a random order.
 *
 * The canvas is sized to the face, at up to twice the screen's density, and only while it shows
 * something being scratched or dissolving: `release` gives its pixels back.
 */

export type Point = { x: number; y: number };

export type Scratch = {
  /** The share of cells revealed, 0 to 1. */
  revealed: () => number;
  /** Scratches along a segment, in face units. */
  stroke: (from: Point, to: Point) => void;
  /** Reveals every cell (or covers every cell, `cover`), dissolving over `ms`; `done` follows. */
  dissolve: (cover: boolean, ms: number, done: () => void) => void;
  /** Reveals or covers every cell at once, without drawing. */
  fill: (open: boolean) => void;
  /** Draws now, rather than at the next frame. */
  redraw: () => void;
  /** Paints the price lit by the card's glow, or in the dark. */
  setLit: (lit: boolean) => void;
  /** Gives the canvas's pixels back; the cells are kept. */
  release: () => void;
  /** Stops any dissolve and gives everything back. */
  dispose: () => void;
};

/** The brush's reach, in face units. */
const BRUSH = 15;
const COUNT = GRID.cols * GRID.rows;

export function createScratch(canvas: HTMLCanvasElement, density: () => number): Scratch {
  const cells = new Uint8Array(COUNT);
  let open = 0;
  let lit = true;
  let frame = 0;
  let dissolving = 0;
  let covering = false;
  // The prize, painted once at the canvas's size; the mask, one pixel per cell.
  const art = document.createElement('canvas');
  const mask = document.createElement('canvas');
  mask.width = GRID.cols;
  mask.height = GRID.rows;
  const maskContext = mask.getContext('2d');
  const pixels = maskContext?.createImageData(GRID.cols, GRID.rows) ?? null;
  let painted = false;

  /** Sizes the canvases to the face and paints the prize, if they gave their pixels back. */
  const prepare = () => {
    const scale = density();
    const width = Math.max(1, Math.round(canvas.offsetWidth * scale));
    const height = Math.max(1, Math.round(canvas.offsetHeight * scale));
    if (painted && canvas.width === width && canvas.height === height) return;
    canvas.width = art.width = width;
    canvas.height = art.height = height;
    const context = art.getContext('2d');
    if (!context) return;
    const sx = width / FACE.width;
    const sy = height / FACE.height;
    // The slabs, row by row, on whole pixels, so that neighbouring rows leave no seam.
    context.fillStyle = SLAB_COLOR;
    for (const [row, col, length] of SLAB_RUNS) {
      const x = Math.floor(col * CELL.width * sx);
      const y = Math.floor(row * CELL.height * sy);
      context.fillRect(
        x,
        y,
        Math.ceil((col + length) * CELL.width * sx) - x,
        Math.ceil((row + 1) * CELL.height * sy) - y,
      );
    }
    context.setTransform(sx, 0, 0, sy, 0, 0);
    const colors = lit ? PRICE_LIT : PRICE_DIM;
    const gradient = context.createLinearGradient(FACE.width * 0.22, 0, FACE.width * 0.78, 0);
    colors.forEach((color, i) => gradient.addColorStop(i / (colors.length - 1), color));
    context.font = PRICE_FONT;
    context.textAlign = 'center';
    context.textBaseline = 'alphabetic';
    context.fillStyle = gradient;
    context.fillText(PRICE.text, PRICE.x, PRICE.y);
    // Lit from below: the price's upper half in shade, painted over what is already drawn only.
    const shade = context.createLinearGradient(0, PRICE_SHADE.top, 0, PRICE_SHADE.clear);
    shade.addColorStop(0, PRICE_SHADE.color);
    shade.addColorStop(1, `${PRICE_SHADE.color}00`);
    context.globalAlpha = PRICE_SHADE.alpha;
    context.globalCompositeOperation = 'source-atop';
    context.fillStyle = shade;
    context.fillRect(0, PRICE_SHADE.top, FACE.width, PRICE_SHADE.clear - PRICE_SHADE.top);
    context.globalCompositeOperation = 'source-over';
    context.globalAlpha = 1;
    painted = true;
  };

  /** Shows the prize through the cells revealed so far. */
  const draw = () => {
    cancelAnimationFrame(frame);
    frame = 0;
    const context = canvas.getContext('2d');
    if (!context || !maskContext || !pixels) return;
    prepare();
    for (let i = 0; i < COUNT; i += 1) pixels.data[i * 4 + 3] = cells[i] ? 255 : 0;
    maskContext.putImageData(pixels, 0, 0);
    context.setTransform(1, 0, 0, 1, 0, 0);
    context.globalCompositeOperation = 'copy';
    context.drawImage(art, 0, 0);
    context.globalCompositeOperation = 'destination-in';
    context.imageSmoothingEnabled = false;
    context.drawImage(mask, 0, 0, canvas.width, canvas.height);
    context.globalCompositeOperation = 'source-over';
  };
  const schedule = () => {
    if (!frame) frame = requestAnimationFrame(draw);
  };

  const set = (i: number, on: boolean) => {
    if (cells[i] === (on ? 1 : 0)) return;
    cells[i] = on ? 1 : 0;
    open += on ? 1 : -1;
  };

  const stroke: Scratch['stroke'] = (from, to) => {
    // A reader interrupts the demo immediately, keeping the pixel mask.
    cancelAnimationFrame(dissolving);
    dissolving = 0;
    if (covering) {
      cells.fill(0);
      open = 0;
      covering = false;
    }
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    const length = dx * dx + dy * dy;
    // Only the cells in the segment's box, grown by the brush.
    const c0 = Math.max(0, Math.floor((Math.min(from.x, to.x) - BRUSH) / CELL.width));
    const c1 = Math.min(GRID.cols - 1, Math.floor((Math.max(from.x, to.x) + BRUSH) / CELL.width));
    const r0 = Math.max(0, Math.floor((Math.min(from.y, to.y) - BRUSH) / CELL.height));
    const r1 = Math.min(GRID.rows - 1, Math.floor((Math.max(from.y, to.y) + BRUSH) / CELL.height));
    for (let row = r0; row <= r1; row += 1) {
      for (let col = c0; col <= c1; col += 1) {
        const x = (col + 0.5) * CELL.width;
        const y = (row + 0.5) * CELL.height;
        // The nearest point of the segment to the cell's middle.
        const t = length
          ? Math.min(1, Math.max(0, ((x - from.x) * dx + (y - from.y) * dy) / length))
          : 0;
        const ex = from.x + dx * t - x;
        const ey = from.y + dy * t - y;
        if (ex * ex + ey * ey <= BRUSH * BRUSH) set(row * GRID.cols + col, true);
      }
    }
    schedule();
  };

  const dissolve: Scratch['dissolve'] = (cover, ms, done) => {
    cancelAnimationFrame(dissolving);
    covering = cover;
    const order: number[] = [];
    for (let i = 0; i < COUNT; i += 1) if (cells[i] === (cover ? 1 : 0)) order.push(i);
    // A random order: each cell trades places with one at random.
    for (let i = order.length - 1; i > 0; i -= 1) {
      const j = Math.floor(Math.random() * (i + 1));
      [order[i], order[j]] = [order[j], order[i]];
    }
    let changed = 0;
    let start = -1;
    const tick = (now: number) => {
      if (start < 0) start = now;
      // A frame's time can come before the clock read when the dissolve began: clamp.
      const progress = ms > 0 ? Math.min(1, Math.max(0, (now - start) / ms)) : 1;
      const target = Math.round(order.length * progress);
      for (; changed < target; changed += 1) set(order[changed], !cover);
      draw();
      if (progress < 1) dissolving = requestAnimationFrame(tick);
      else {
        dissolving = 0;
        covering = false;
        done();
      }
    };
    dissolving = requestAnimationFrame(tick);
  };

  const release = () => {
    cancelAnimationFrame(frame);
    frame = 0;
    canvas.width = canvas.height = art.width = art.height = 0;
    painted = false;
  };

  return {
    revealed: () => open / COUNT,
    stroke,
    dissolve,
    redraw: draw,
    fill: (on) => {
      cancelAnimationFrame(dissolving);
      dissolving = 0;
      covering = false;
      cells.fill(on ? 1 : 0);
      open = on ? COUNT : 0;
    },
    setLit: (next) => {
      if (next === lit) return;
      lit = next;
      if (!painted) return;
      painted = false;
      schedule();
    },
    release,
    dispose: () => {
      cancelAnimationFrame(dissolving);
      dissolving = 0;
      release();
    },
  };
}
```

### scratch-card/art.tsx

```tsx
/*
 * What the scratch card hides, and the grid it is scratched on. The face is measured in units
 * (FACE), and divided into cells (GRID) as fine as its printed dots: a cell is scratched off or
 * not, so the edge of what shows steps like the dots. Under the cover lie two isometric cards in
 * black, drawn cell by cell, and the price.
 *
 * The canvas paints all this while the card is being scratched (scratch.ts); once it is all
 * revealed, the same drawing as SVG (Prize) takes its place, so the canvas can give its pixels back.
 */

/** The face's size, in the card's units (the card is 200 wide). */
export const FACE = { width: 184.6, height: 94.5 };
/** The cells it is scratched by: two to a dot across, one to a row of dots down. */
export const GRID = { cols: 64, rows: 32 };
export const CELL = { width: FACE.width / GRID.cols, height: FACE.height / GRID.rows };

/** The price, its place (centre across, baseline down) and its size, in face units. */
export const PRICE = { text: '$299', x: 92.5, y: 53, size: 35.4 };
/** The price's type, the same for the canvas and for the SVG. */
export const PRICE_FONT = `600 ${PRICE.size}px Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;

/** The price's colours across: lit by the card's glow, and in the dark. */
export const PRICE_LIT = ['#49eef7', '#71daf3', '#a9c9dd', '#8b9daa'];
export const PRICE_DIM = ['#4b4f53', '#55595d', '#5c6064', '#55595c'];
/** The price is lit from below: a shade over its upper half, from its cap line (top, in face
 * units) to where it clears, and how dark it is there. */
export const PRICE_SHADE = {
  top: PRICE.y - PRICE.size * 0.73,
  clear: PRICE.y - 8,
  color: '#080c10',
  alpha: 0.7,
};

type Point = { x: number; y: number };

/** An isometric card: a corner, and its long and short sides along the isometric axes. */
type Slab = { corner: Point; long: number; short: number };

/** Two black cards seen from above, one behind the other, running up to the right. */
const SLABS: Slab[] = [
  { corner: { x: 8.3, y: 48.7 }, long: 170, short: 34 },
  { corner: { x: 58.9, y: 67 }, long: 114, short: 35 },
];

const LONG = { x: Math.cos(Math.PI / 6), y: -0.5 };
const SHORT = { x: Math.cos(Math.PI / 6), y: 0.5 };

/** Whether a point lies on a slab: its coordinates along the slab's two sides fall within them. */
function onSlab({ corner, long, short }: Slab, x: number, y: number) {
  const dx = x - corner.x;
  const dy = y - corner.y;
  // Solve corner + a·LONG + b·SHORT = point.
  const det = LONG.x * SHORT.y - LONG.y * SHORT.x;
  const a = (dx * SHORT.y - dy * SHORT.x) / det;
  const b = (LONG.x * dy - LONG.y * dx) / det;
  return a >= 0 && a <= long && b >= 0 && b <= short;
}

/** For each cell, row by row, whether a slab covers its middle. */
export const SLAB_CELLS: boolean[] = Array.from({ length: GRID.cols * GRID.rows }, (_, i) => {
  const x = ((i % GRID.cols) + 0.5) * CELL.width;
  const y = (Math.floor(i / GRID.cols) + 0.5) * CELL.height;
  return SLABS.some((slab) => onSlab(slab, x, y));
});

/** The slabs' cells as runs along each row: [row, first column, length]. */
export const SLAB_RUNS: [number, number, number][] = [];
for (let row = 0; row < GRID.rows; row += 1) {
  let start = -1;
  for (let col = 0; col <= GRID.cols; col += 1) {
    const on = col < GRID.cols && SLAB_CELLS[row * GRID.cols + col];
    if (on && start < 0) start = col;
    if (!on && start >= 0) {
      SLAB_RUNS.push([row, start, col - start]);
      start = -1;
    }
  }
}

const round = (value: number) => Math.round(value * 100) / 100;

/** The slabs as one SVG path, a rectangle for each run, drawn with crisp edges so rows meet. */
const SLAB_PATH = SLAB_RUNS.map(
  ([row, col, length]) =>
    `M${round(col * CELL.width)} ${round(row * CELL.height)}h${round(length * CELL.width)}v${round(CELL.height + 0.04)}h${round(-length * CELL.width)}z`,
).join('');

/** The slabs' colour. */
export const SLAB_COLOR = '#070707';

/**
 * The prize, drawn in SVG as the canvas draws it: the slabs and the price, lit or in the dark.
 * `ids` prefixes its gradients, unique to the card.
 */
export function Prize({ lit, ids }: { lit: boolean; ids: string }) {
  const colors = lit ? PRICE_LIT : PRICE_DIM;
  const id = `${ids}-${lit ? 'lit' : 'dim'}`;
  return (
    <svg viewBox={`0 0 ${FACE.width} ${FACE.height}`} preserveAspectRatio="none" aria-hidden="true">
      <defs>
        <linearGradient id={id} x1="0.22" x2="0.78" y1="0" y2="0">
          {colors.map((color, i) => (
            <stop key={i} offset={i / (colors.length - 1)} stopColor={color} />
          ))}
        </linearGradient>
        <linearGradient
          id={`${id}-shade`}
          gradientUnits="userSpaceOnUse"
          x1="0"
          x2="0"
          y1={PRICE_SHADE.top}
          y2={PRICE_SHADE.clear}
        >
          <stop offset="0" stopColor={PRICE_SHADE.color} stopOpacity={PRICE_SHADE.alpha} />
          <stop offset="1" stopColor={PRICE_SHADE.color} stopOpacity="0" />
        </linearGradient>
      </defs>
      <path d={SLAB_PATH} fill={SLAB_COLOR} shapeRendering="crispEdges" />
      {[id, `${id}-shade`].map((fill) => (
        <text
          key={fill}
          x={PRICE.x}
          y={PRICE.y}
          textAnchor="middle"
          fill={`url(#${fill})`}
          style={{ font: PRICE_FONT }}
        >
          {PRICE.text}
        </text>
      ))}
    </svg>
  );
}

/** The card's mark: a four-pointed spark, in the colour its class gives it. */
export function Mark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path d="M8 .8 9.7 6.3 15.2 8 9.7 9.7 8 15.2 6.3 9.7.8 8l5.5-1.7z" fill="currentColor" />
    </svg>
  );
}
```

### scratch-card/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a control and press it, or press and drag in a stroke, calling the
 * component's own handlers as a hand would. Stopping the demo (the reader takes over, or it
 * scrolls away) ends the step in progress at once: its promise rejects, which ends the script
 * right there, and the pointer fades out.
 *
 * Nothing the pointer does while the demo plays needs a layout, because one here in the same
 * frame as one in the card would make the browser lay out the whole page (see the note at the
 * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged
 * value follows that animation's clock); the press scales a box that keeps a transform at rest,
 * since a box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes
 * that swap by their visibility (a change of style inside an SVG lays the SVG out again).
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Glides the pointer to a point of the screen, in client px. */
  moveToPoint: (point: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /**
   * Grabs, drags to a point of the screen (client px) over `ms`, and lets go. `step` gets the
   * share of the way done on every frame, so the dragged value moves with the hand.
   */
  drag: (to: Point, ms: number, step: (share: number) => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, in its 20 × 20 box: the point the pointer acts on. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/**
 * Plays `script` with the pointer over `stage`; returns the function that stops it. `stopped` runs
 * when the demo stops, so the script can let go of what it holds.
 */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
  stopped?: () => void,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  let frame = 0;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.82, y: stage.offsetHeight * 0.92 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const frameBox = stage.getBoundingClientRect();
    const scale = frameBox.width / stage.offsetWidth || 1;
    return { x: (point.x - frameBox.left) / scale, y: (point.y - frameBox.top) / scale };
  };

  const glide = (to: Point) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return glide(toStage({ x: box.left + box.width * share.x, y: box.top + box.height * share.y }));
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  const drag: Demo['drag'] = (point, ms, onStep) => {
    const from = at;
    const to = toStage(point);
    at = to;
    pointer.dataset.grab = '';
    // The hand's path, eased, as an animation; the value reads the same clock every frame.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    pointer.style.transform = place(to);
    const motion = pointer.animate(keyframes, { duration: ms, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            delete pointer.dataset.grab;
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({
    wait,
    moveTo,
    moveToPoint: (point) => glide(toStage(point)),
    press,
    drag,
  }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    cancelAnimationFrame(frame);
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    delete pointer.dataset.grab;
    pointer.style.opacity = '0';
    stopped?.();
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses, on a layer of its own. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x * 1.1}px ${TIP.y * 1.1}px`,
  willChange: 'transform',
};

/** One glyph, over the other: the 20 × 20 drawing at 22 px. */
const GLYPH: CSSProperties = { position: 'absolute', top: 0, left: 0, display: 'block' };
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/**
 * The pointer the demo moves around: an arrow, or a closed hand while it scratches. It is
 * decorative: the demo's effects show on the card itself.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <span data-glyph="arrow" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            <path
              d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
              fill="#1c1c1e"
              stroke="#fff"
              strokeWidth="1.3"
              strokeLinejoin="round"
            />
          </svg>
        </span>
        <span data-glyph="hand" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            {/* The hand's middle sits on the arrow's tip. */}
            <g transform="translate(-6.6 -9.4)">
              <path
                d="M6.1 16.6c-1.2-1.2-1.8-2.6-1.8-4.3v-1.6c0-.7.5-1.2 1.1-1.2.2 0 .4 0 .6.1V8.1c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v-.5c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.3c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.7c0-.5.5-1 1-1 .6 0 1.1.5 1.1 1.1v3.7c0 1.9-.8 3.2-1.8 4.1z"
                fill="#fff"
                stroke="#1c1c1e"
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M8.2 8.1v1.7M10.4 7.9v1.9M12.6 8.6v1.5"
                fill="none"
                stroke="#1c1c1e"
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### scratch-card/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```