# Coin counter

> A game wallet readout: gems fly into its icon and the amount rolls up digit by digit, then it slips away.

The HUD readout that tells a player what they hold. Pick up one of six coloured gems and it pops in, flies into the icon with a flash of its colour, and the amount rolls like an odometer, each digit turning as the one below it wraps. Spending rolls it down; spending too much shakes it red. A few idle seconds later the readout fades away. It sits on its own dark plate, as game HUDs keep under their figures, so it reads on a light page as on a dark one.

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

Category: Games · HUD.

## Anatomy

1. **Readout**: The wallet on a dark pill plate, the same on a light or dark stage so its cream figures always read: icon and amount in one row that comes in on a change and fades away after `hideAfter` ms.
2. **Gem icon**: Two green gems, drawn 25 × 45 and 5 px apart at the HUD’s size, 1.4 times that on the stage. A picked gem flies into it, and it swells and glows in that gem’s colour as it lands.
3. **Amount**: Cream figures (32 px at the HUD’s size, 1.4 times that here) with a soft shadow, one odometer window per digit; the digits roll to each new amount and carry one place at a time.
4. **Change**: The last change on a small plate under the readout, “+20” glowing in the gem’s colour or “−500” in warm red; quick changes of the same sign add up into it.
5. **Gem row**: A neutral lab chip of buttons under the readout, one per gem kind with its worth under it; keys 1 to 9 pick them up. The readout stays while the row is in use.
6. **Spend button**: A neutral pill chip that spends `price`, the price in warm red; minus spends too. It is left out when `price` is 0.

## When to use

- A game’s wallet: one currency the player gains and spends often, read in a corner of the screen without stopping play.
- Gains worth celebrating a little: a pickup flies into the icon and the amount rolls, so the player sees what they got as it lands.
- A HUD that should stay out of the way: the readout slides in on each change and slips away a few seconds later.
- Refusing a purchase the player cannot afford, with a shake and a red flash rather than a dialog.
- Pickups in kinds of different worth (here six gem colours), each with its own glow when it lands.

## When not to use

- A balance in an app or a bank: money read carefully and rarely changing in bursts. Use a plain figure with its currency, or a stat card.
- Several currencies or resources at once. Use a resource bar that keeps every one on screen.
- A score that only ever climbs and is always visible. Use a static score label; hiding it would only get in the way.
- A count the player must check at any moment, such as ammunition in a fight. Use an always-on counter, or keep this one up with `hideAfter={0}`.
- A shop itself, with items, prices and confirmation. Use a shop menu; this only shows the wallet beside it.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/coin-counter.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 { CoinCounter } from './coin-counter';

export function Wallet({ gems, setGems }: { gems: number; setGems: (n: number) => void }) {
  return (
    <CoinCounter
      value={gems}
      onChange={(next) => setGems(next)}
      price={250}
      hideAfter={4000}
      autoplay={false}
    />
  );
}
```

## States

- **At rest**: `defaultState="shown"`: the readout on its plate with no change under it, held until the reader acts.
- **Gem picked up**: `defaultState="gain"`: just after a red gem, “+20” under the amount with a red-orange glow.
- **Spent**: `defaultState="spend"`: just after a purchase, “−500” in warm red under the amount.
- **Not enough**: `defaultState="short"` with 320 held: the amount flashes red, as when the Spend button cannot be paid.
- **Hidden**: `defaultState="hidden"`: the readout has slipped away; the next change, or using the gem row, brings it back.
- **Without controls**: `showControls={false}`: the readout alone, driven by `value` from the game.
- **Light**: `colorScheme="light"`, or a light page: a white stage and pale chips; the readout keeps its dark plate.
- **Dark**: `colorScheme="dark"`, or a dark page: a near-black stage and dark chips; the plate’s edge lifts a step so it parts from the stage, and the figures and gems stay the same.

## How it works

- Each digit is a strip of eleven figures (0 to 9, then 0 again) behind a one-line window. One number, the amount on its way from the old value to the new, places every strip: the units follow it continuously, and each higher digit turns only during the last unit before the digits below it wrap, so a carry ripples up one digit at a time.
- The roll lasts 240 ms for a single gem and grows with the log of the change, to under a second for hundreds, eased out. It writes only transforms, once per frame, outside React; React draws the strips at the settled amount.
- A digit with nothing above it folds to zero width, so the amount keeps its shape as it gains or loses a digit, and the thousands comma folds with it.
- A pickup is one beat: the gem pops in under the readout (small, overshooting, settling), then flies up into the icon on a CSS keyframe path in 470 ms. As it lands the icon swells and a glow in that gem’s colour flares behind it (Web Animations API), and the roll starts.
- The change shows as “+20” under the amount; changes of the same sign within 1.2 s add up into it, as a combo. Spending shows “−500” in warm red; spending more than the wallet holds shakes the readout and flashes the amount red instead.
- The readout hides itself a few seconds after the last change (hideAfter), and stays while the pointer or the focus is on the gem row, as a wallet stays open in a shop.
- The gems are one 25 × 45 drawing of seven flat facets lit from the top left; each colour is a set of facet tokens in the CSS. Everything is SVG and text, so it stays sharp at any size and pixel density.
- The icon and amount sit on a dark pill plate, and the change label on a smaller one under it: the cream figures need a dark ground, so the plate is the same on both colour schemes and only its edge lifts at night. The readout is drawn at 1.4 times the HUD’s own sizes (1.15 on a narrow stage), from one --k factor in the CSS.
- While on screen and untouched, a demo picks up a run of gems, buys something and lets the readout hide, then starts over from the same amount. Any real input hands it over.

## API reference

- **value** (`number`): The amount, controlled: the counter rolls to each new value. Leave it out to let the counter keep its own.
- **defaultValue** (`number`, default `13878`): The starting amount when uncontrolled, and where the demo starts each run.
- **onChange** (`(value: number, change: number, color: GemColor | null) => void`): Called with the new amount, the change and the gem picked up (null for a purchase).
- **gems** (`{ color: 'green' | 'blue' | 'red' | 'purple' | 'silver' | 'gold'; value: number; label?: string }[]`, default `GEMS`): The gems the row offers, in order, and what each is worth (1, 5, 20, 50, 100 and 300 by default). Keys 1 to 9 pick them up.
- **price** (`number`, default `500`): What the Spend button costs. 0 hides it.
- **hideAfter** (`number`, default `3200`): The readout hides this many ms after the last change; 0 keeps it on screen.
- **showControls** (`boolean`, default `true`): Shows the row of gems and the Spend button.
- **unit** (`string`, default `'gems'`): What the amount counts, for screen readers.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo picks up gems, buys something and lets the readout hide.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark stage and chips, or the reader’s; the readout keeps its dark plate on both. Without it, the page’s color-scheme.
- **defaultState** (`'shown' | 'hidden' | 'gain' | 'spend' | 'short'`): Opens held in one moment until the reader picks up or spends: the readout at rest, hidden, just after a red gem (“+20”), just after a purchase (“−500”), or refusing one in red. Turns the demo off.
- **className** (`string`): Added to the root.

## Accessibility

- The readout carries its amount as text for screen readers (“13,878 gems”); the rolling figures, the icon and the flying gems are hidden from them.
- A polite live region says each change once it settles (“Plus 20. 13,898 gems.”), and says when a purchase is refused.
- The gems are real buttons named with their worth (“Red gem, plus 20”), in a labelled group; keys 1 to 9 pick them up and minus spends, announced with aria-keyshortcuts.
- Every button shows a focus ring, and the readout stays on screen while the focus is on the row.
- Under reduced motion the amount changes at once, with no roll, flight, swell or shake; the change label only fades, and the demo stays off.

## Source

### coin-counter/coin-counter.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { Gem, type GemColor } from './gem';
import { useAutoplay } from './use-autoplay';
import styles from './coin-counter.module.css';

/*
 * Coin counter: a game HUD's wallet. A pair of gem icons and an amount on a dark plate, shown on
 * its own; it comes in when the amount changes, plays the change out, and slips away a few seconds
 * later. Gems come in six colours, each worth a set amount, and a row of them below picks one up.
 *
 * - The amount rolls like an odometer. Each digit is a strip of eleven figures (0 to 9, then 0
 *   again) in a one-line window; one number, the amount on its way from the old value to the new,
 *   places every strip. The units follow it continuously; each higher digit holds still and turns
 *   only while the digits below it wrap from 9 to 0, so a carry ripples up one digit at a time.
 *   The roll lasts longer for bigger changes (240 ms for one, under a second for hundreds), eased
 *   out, and writes only transforms, once per frame, outside React.
 * - A pickup is a short beat: a gem of that colour pops in under the readout (small, overshoot,
 *   settle), flies up into the icon and vanishes; the icon swells and glows in that colour as it
 *   lands, and the roll starts. A "+20" label pops under the amount; quick pickups add up into it
 *   rather than stacking.
 * - Spending rolls the amount down with a warm red "−500". Spending more than the wallet holds
 *   shakes the readout and flashes the amount red instead.
 * - The readout hides itself `hideAfter` ms after the last change, and stays while the pointer or
 *   the focus is on the gem row (as a wallet stays open in a shop).
 * - While on screen and untouched, a demo plays a short run of pickups and a purchase, lets the
 *   readout hide, and starts again from the same amount.
 * - Reduced motion: no flights, no roll and no shake; the amount changes at once.
 * - Light or dark: the stage and the chips under the readout follow light-dark() tokens; the
 *   readout keeps its dark plate, cream figures and gem colours on both, as game art does, so it
 *   reads on a white stage as on a black one.
 */

export type { GemColor };

/** A kind of gem the row offers: its colour and what it is worth. */
export type GemKind = { color: GemColor; value: number; label?: string };

/** The six gems and their worth. */
export const GEMS: GemKind[] = [
  { color: 'green', value: 1 },
  { color: 'blue', value: 5 },
  { color: 'red', value: 20 },
  { color: 'purple', value: 50 },
  { color: 'silver', value: 100 },
  { color: 'gold', value: 300 },
];

/** A picked gem pops, then flies into the icon: it lands this many ms after the pickup. */
const LAND = 430;
/** How long a "+20" label stays after the last change it counts. */
const LABEL_HOLD = 1200;
/** A digit strip holds eleven figures: 0 to 9, and a 0 again for the wrap. */
const FIGURES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];

/** How long the amount takes to roll by `change`: longer for bigger changes, never sluggish. */
function rollTime(change: number) {
  const size = Math.abs(change);
  if (size < 1) return 0;
  return Math.min(1100, 240 + 260 * Math.log10(size));
}

const easeOut = (t: number) => 1 - (1 - t) ** 3;

/** How many digits `value` has (at least one). */
const digitsOf = (value: number) => Math.max(1, String(Math.floor(Math.abs(value))).length);

/**
 * Where the strip of digit `k` (0 for the units) stands for the amount `value`, in figures from
 * the top: the units follow the amount; a higher digit turns only during the last unit before the
 * digits below it wrap.
 */
function stripPosition(value: number, k: number) {
  if (k === 0) return value % 10;
  const place = 10 ** k;
  const whole = Math.floor(value / place);
  const below = value - whole * place;
  return (whole % 10) + Math.max(0, below - (place - 1));
}

/** A digit with nothing above it shows only once the amount reaches its place. */
const isLeading = (value: number, k: number) => k > 0 && value <= 10 ** k - 1;

const stripTransform = (position: number) =>
  `translateY(${((-position * 100) / FIGURES.length).toFixed(3)}%)`;

const format = (value: number) => Math.round(value).toLocaleString('en-US');

type Label = { amount: number; color: GemColor | null; key: number };

/** A moment the counter can open on, held until the reader acts: the readout at rest or hidden,
 * just after a red gem ("+20"), just after a purchase ("−500"), or refusing one it cannot pay. */
export type CoinCounterState = 'shown' | 'hidden' | 'gain' | 'spend' | 'short';

const OPENING_LABEL: Partial<Record<CoinCounterState, Omit<Label, 'key'>>> = {
  gain: { amount: 20, color: 'red' },
  spend: { amount: -500, color: null },
};
type Flight = { id: number; color: GemColor };

export function CoinCounter({
  value,
  defaultValue = 13878,
  onChange,
  gems = GEMS,
  price = 500,
  hideAfter = 3200,
  showControls = true,
  unit = 'gems',
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The amount, controlled: the counter rolls to each new value. Leave it out to let the counter keep its own. */
  value?: number;
  /** The starting amount when uncontrolled (and where the demo starts each run). */
  defaultValue?: number;
  /** Called with the new amount and the change, on a pickup or a purchase. */
  onChange?: (value: number, change: number, color: GemColor | null) => void;
  /** The gems the row offers, in order; keys 1 to 9 pick them up. */
  gems?: GemKind[];
  /** What the Spend button costs. 0 hides it. */
  price?: number;
  /** The readout hides this many ms after the last change; 0 keeps it on screen. */
  hideAfter?: number;
  /** Shows the row of gems and the Spend button under the readout. */
  showControls?: boolean;
  /** What the amount counts, for screen readers. */
  unit?: string;
  /** While on screen and untouched, a demo picks up gems, buys something, and lets the readout hide. */
  autoplay?: boolean;
  /** The stage and chips light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens held in one moment (and turns the demo off) until the reader picks up or spends. */
  defaultState?: CoinCounterState;
  className?: string;
}) {
  const { ref: root, playing, reduced, take } = useAutoplay<HTMLDivElement>(5000);
  const [own, setOwn] = useState(defaultValue);
  const amount = Math.max(0, Math.round(value ?? own));

  /** The amount the strips stand at when nothing rolls (React draws them there). */
  const [settled, setSettled] = useState(amount);
  /** How many digit windows there are: enough for both ends of the current roll. */
  const [digits, setDigits] = useState(digitsOf(amount));
  const [shown, setShown] = useState(defaultState !== 'hidden');
  const [holding, setHolding] = useState(false);
  const openingLabel = defaultState ? OPENING_LABEL[defaultState] : undefined;
  const [label, setLabel] = useState<Label | null>(
    openingLabel ? { ...openingLabel, key: 0 } : null,
  );
  const [labelOn, setLabelOn] = useState(!!openingLabel);
  const [flights, setFlights] = useState<Flight[]>([]);
  const [land, setLand] = useState<GemColor>(openingLabel?.color ?? 'green');
  const [warn, setWarn] = useState(defaultState === 'short');
  /** The moment it opened on, held (no hiding, no showing) until the first change or hold. */
  const opening = useRef(defaultState);
  const [message, setMessage] = useState('');
  const [pressed, setPressed] = useState<string | null>(null);

  const hud = useRef<HTMLDivElement>(null);
  const icon = useRef<HTMLSpanElement>(null);
  const glow = useRef<HTMLSpanElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const strips = useRef<(HTMLSpanElement | null)[]>([]);
  const windows = useRef<(HTMLSpanElement | null)[]>([]);
  const commas = useRef<(HTMLSpanElement | null)[]>([]);

  /** The amount the strips show right now, between frames of a roll. */
  const current = useRef(amount);
  const frame = useRef(0);
  const rolling = useRef(false);
  /** The next change comes from a pickup: wait for its gem to land, and colour its label. */
  const pending = useRef<{ color: GemColor | null; delay: number } | null>(null);
  /** The next change is the demo's reset: jump there, hidden, with no label. */
  const jump = useRef(false);
  const previous = useRef(amount);
  const labelSum = useRef<{ amount: number; until: number }>({ amount: 0, until: 0 });
  const labelKey = useRef(0);
  const flightId = useRef(0);
  const timers = useRef(new Set<number>());
  /** Read when a roll ends, so a change of them alone is not a roll. */
  const holdingRef = useRef(holding);
  const unitRef = useRef(unit);
  useEffect(() => {
    holdingRef.current = holding;
    unitRef.current = unit;
  });

  const later = useCallback((fn: () => void, ms: number) => {
    const id = window.setTimeout(() => {
      timers.current.delete(id);
      fn();
    }, ms);
    timers.current.add(id);
    return id;
  }, []);
  useEffect(() => {
    const all = timers.current;
    return () => {
      for (const id of all) window.clearTimeout(id);
      cancelAnimationFrame(frame.current);
    };
  }, []);

  /** Places every strip for the amount `at` (and hides digits above it), outside React. */
  const paint = useCallback((at: number) => {
    strips.current.forEach((strip, k) => {
      if (!strip) return;
      strip.style.transform = stripTransform(stripPosition(at, k));
      const lead = String(isLeading(at, k));
      windows.current[k]?.setAttribute('data-leading', lead);
      commas.current[k]?.setAttribute('data-leading', lead);
    });
  }, []);

  /** The readout's hide timer: restarted by every change, held while the gem row is in use. */
  const hideTimer = useRef(0);
  const scheduleHide = useCallback(() => {
    window.clearTimeout(hideTimer.current);
    if (hideAfter <= 0) return;
    hideTimer.current = window.setTimeout(() => setShown(false), hideAfter);
  }, [hideAfter]);
  useEffect(() => {
    if (opening.current && !holding) return;
    opening.current = undefined;
    if (holding || hideAfter <= 0) {
      window.clearTimeout(hideTimer.current);
      setShown(true);
    } else if (!rolling.current) scheduleHide();
    return () => window.clearTimeout(hideTimer.current);
  }, [holding, hideAfter, scheduleHide]);

  /** Every change of the amount: show the readout, label the change, and roll to it. */
  useEffect(() => {
    const from = previous.current;
    const to = amount;
    if (from === to) return;
    previous.current = to;
    opening.current = undefined;
    cancelAnimationFrame(frame.current);

    if (jump.current) {
      jump.current = false;
      rolling.current = false;
      current.current = to;
      setDigits(digitsOf(to));
      setSettled(to);
      paint(to);
      return;
    }

    const source = pending.current;
    pending.current = null;
    const change = to - from;
    setShown(true);
    window.clearTimeout(hideTimer.current);

    // The label: quick changes of the same sign add up into it.
    const now = performance.now();
    const sum = labelSum.current;
    const total =
      now < sum.until && Math.sign(sum.amount) === Math.sign(change) ? sum.amount + change : change;
    labelSum.current = { amount: total, until: now + LABEL_HOLD };
    const labelDelay = source && !reduced ? source.delay - 120 : 0;
    later(() => {
      setLabel({ amount: total, color: source?.color ?? null, key: ++labelKey.current });
      setLabelOn(true);
    }, labelDelay);
    later(() => {
      if (performance.now() >= labelSum.current.until - 10) setLabelOn(false);
    }, labelDelay + LABEL_HOLD);

    const settle = () => {
      rolling.current = false;
      current.current = to;
      setSettled(to);
      setDigits(digitsOf(to));
      paint(to);
      setMessage(
        `${change > 0 ? 'Plus' : 'Minus'} ${format(Math.abs(change))}. ${format(to)} ${unitRef.current}.`,
      );
      if (!holdingRef.current) scheduleHide();
    };

    if (reduced) {
      settle();
      return;
    }

    // Roll from wherever the strips are (a roll may be under way) to the new amount.
    const start = current.current;
    const duration = rollTime(to - start);
    setDigits(Math.max(digitsOf(start), digitsOf(to)));
    rolling.current = true;
    const delay = source?.delay ?? 0;
    const begin = performance.now() + delay;
    const step = (time: number) => {
      const t = Math.min(1, Math.max(0, (time - begin) / duration));
      const at = start + (to - start) * easeOut(t);
      current.current = at;
      paint(at);
      if (t < 1) frame.current = requestAnimationFrame(step);
      else settle();
    };
    frame.current = requestAnimationFrame(step);
  }, [amount, reduced, paint, later, scheduleHide]);

  // New digit windows (a roll that adds a digit) are drawn at the amount the strips stand at.
  useEffect(() => {
    paint(current.current);
  }, [digits, paint]);

  // The label pops each time it changes (not the one it opens on, which is simply there).
  useEffect(() => {
    if (!label || label.key === 0 || reduced) return;
    labelRef.current?.animate(
      [
        { transform: 'translateY(6px) scale(0.6)', opacity: 0 },
        { transform: 'translateY(-2px) scale(1.12)', opacity: 1, offset: 0.55 },
        { transform: 'translateY(0) scale(1)', opacity: 1 },
      ],
      { duration: 260, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)' },
    );
  }, [label, reduced]);

  /** The icon swells and glows in the colour of the gem that lands in it. */
  const pulse = useCallback(
    (color: GemColor, delay: number) => {
      setLand(color);
      if (reduced) return;
      icon.current?.animate(
        [
          { transform: 'scale(1)' },
          { transform: 'scale(1.14)', offset: 0.3 },
          { transform: 'scale(0.97)', offset: 0.65 },
          { transform: 'scale(1)' },
        ],
        { duration: 380, delay, easing: 'ease-out' },
      );
      glow.current?.animate(
        [
          { opacity: 0, transform: 'scale(0.6)' },
          { opacity: 1, transform: 'scale(1.05)', offset: 0.25 },
          { opacity: 0, transform: 'scale(1.3)' },
        ],
        { duration: 620, delay, easing: 'ease-out' },
      );
    },
    [reduced],
  );

  const change = useCallback(
    (next: number, delta: number, color: GemColor | null) => {
      if (value === undefined) setOwn(next);
      onChange?.(next, delta, color);
    },
    [value, onChange],
  );

  /** Picks up a gem: it pops in, flies into the icon, and the amount rolls on landing. */
  const pickUp = useCallback(
    (gem: GemKind) => {
      pending.current = { color: gem.color, delay: reduced ? 0 : LAND };
      if (!reduced) {
        const id = ++flightId.current;
        setFlights((all) => [...all.slice(-5), { id, color: gem.color }]);
      }
      pulse(gem.color, reduced ? 0 : LAND - 40);
      change(amount + gem.value, gem.value, gem.color);
    },
    [amount, change, pulse, reduced],
  );

  /** Spends `price`, or shakes and flashes red when the wallet holds less. */
  const spend = useCallback(() => {
    opening.current = undefined;
    setShown(true);
    if (amount < price) {
      setWarn(true);
      later(() => setWarn(false), 650);
      setMessage(`Not enough ${unit}: ${format(price)} needed, ${format(amount)} held.`);
      if (!reduced)
        hud.current?.animate(
          [
            { transform: 'translateX(0)' },
            { transform: 'translateX(-7px)' },
            { transform: 'translateX(6px)' },
            { transform: 'translateX(-4px)' },
            { transform: 'translateX(2px)' },
            { transform: 'translateX(0)' },
          ],
          { duration: 380, easing: 'ease-out' },
        );
      if (!holding) scheduleHide();
      return;
    }
    pending.current = null;
    change(amount - price, -price, null);
  }, [amount, price, unit, reduced, holding, later, scheduleHide, change]);

  /** Keys 1 to 9 pick up the gems in order; minus spends. */
  const onKeyDown = (event: ReactKeyboardEvent) => {
    take();
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const index = Number(event.key) - 1;
    if (Number.isInteger(index) && index >= 0 && index < gems.length) {
      event.preventDefault();
      pickUp(gems[index]);
    } else if ((event.key === '-' || event.key === '_') && price > 0) {
      event.preventDefault();
      spend();
    }
  };

  // The demo: a short run of pickups and a purchase, then the readout hides and it starts over.
  const demo = autoplay && defaultState === undefined && playing;
  const run = useRef({ pickUp, spend });
  run.current = { pickUp, spend };
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const at = (ms: number, fn: () => void) => ids.push(window.setTimeout(fn, ms));
    const press = (key: string) => {
      setPressed(key);
      ids.push(window.setTimeout(() => setPressed(null), 150));
    };
    const byColor = (color: GemColor) => gems.find((gem) => gem.color === color) ?? gems[0];
    const SCRIPT: [number, GemColor | 'spend'][] = [
      [500, 'green'],
      [900, 'green'],
      [1400, 'blue'],
      [2500, 'red'],
      [2900, 'green'],
      [4100, 'purple'],
      [5700, 'gold'],
      [7700, 'spend'],
    ];
    const LOOP = 13200;
    const play = () => {
      for (const [ms, what] of SCRIPT)
        at(ms, () => {
          if (!gems.length) return;
          press(what === 'spend' ? 'spend' : byColor(what).color);
          if (what === 'spend') run.current.spend();
          else run.current.pickUp(byColor(what));
        });
      // Back to the start while hidden, ready for the next run.
      at(LOOP - 500, () => {
        if (value !== undefined) return;
        jump.current = true;
        setOwn(defaultValue);
      });
      at(LOOP, play);
    };
    play();
    return () => {
      for (const id of ids) window.clearTimeout(id);
      setPressed(null);
    };
  }, [demo, gems, value, defaultValue]);

  const columns = Array.from({ length: digits }, (_, i) => digits - 1 - i);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={onKeyDown}
    >
      <div
        ref={hud}
        className={styles.hud}
        data-part="readout"
        data-shown={shown}
        data-warn={warn}
        data-land={land}
      >
        <span className={styles.srOnly}>
          {format(settled)} {unit}
        </span>
        <span ref={icon} className={styles.icon} data-part="icon" aria-hidden="true">
          <span ref={glow} className={styles.glow} />
          <Gem color="green" />
          <Gem color="green" />
          {flights.map((flight) => (
            <span
              key={flight.id}
              className={styles.flight}
              data-part="flight"
              onAnimationEnd={() =>
                setFlights((all) => all.filter((item) => item.id !== flight.id))
              }
            >
              <Gem color={flight.color} />
            </span>
          ))}
        </span>
        <span className={styles.amount} data-part="amount" aria-hidden="true">
          {columns.map((k) => (
            <span key={k} className={styles.place}>
              <span
                ref={(element) => {
                  windows.current[k] = element;
                }}
                className={styles.window}
                data-part="digit"
                data-leading={isLeading(settled, k)}
              >
                <span
                  ref={(element) => {
                    strips.current[k] = element;
                  }}
                  className={styles.strip}
                  style={{ transform: stripTransform(stripPosition(settled, k)) }}
                >
                  {FIGURES.map((figure, i) => (
                    <span key={i}>{figure}</span>
                  ))}
                </span>
              </span>
              {k > 0 && k % 3 === 0 ? (
                <span
                  ref={(element) => {
                    commas.current[k] = element;
                  }}
                  className={styles.comma}
                  data-leading={isLeading(settled, k)}
                >
                  ,
                </span>
              ) : null}
            </span>
          ))}
        </span>
        <span
          ref={labelRef}
          className={styles.change}
          data-part="change"
          data-on={labelOn}
          data-sign={label && label.amount < 0 ? 'minus' : 'plus'}
          data-color={label?.color ?? undefined}
          aria-hidden="true"
        >
          {label ? `${label.amount < 0 ? '−' : '+'}${format(Math.abs(label.amount))}` : ''}
        </span>
      </div>

      {showControls ? (
        <div
          className={styles.controls}
          data-part="controls"
          role="group"
          aria-label={`Pick up ${unit}`}
          onPointerEnter={(event) => event.pointerType === 'mouse' && setHolding(true)}
          onPointerLeave={() => setHolding(false)}
          onFocus={() => setHolding(true)}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget as Node)) setHolding(false);
          }}
        >
          <div className={styles.gems} data-part="gem-row">
            {gems.map((gem, i) => (
              <button
                key={gem.color + i}
                type="button"
                className={styles.pick}
                data-part="gem"
                data-pressed={pressed === gem.color}
                aria-label={`${gem.label ?? `${capitalise(gem.color)} gem`}, plus ${format(gem.value)}`}
                aria-keyshortcuts={i < 9 ? String(i + 1) : undefined}
                onClick={() => pickUp(gem)}
                style={{ '--i': i } as CSSProperties}
              >
                <Gem color={gem.color} />
                <span className={styles.worth}>{format(gem.value)}</span>
              </button>
            ))}
          </div>
          {price > 0 ? (
            <button
              type="button"
              className={styles.spend}
              data-part="spend"
              data-pressed={pressed === 'spend'}
              aria-keyshortcuts="-"
              onClick={spend}
            >
              Spend <span className={styles.price}>{format(price)}</span>
            </button>
          ) : null}
        </div>
      ) : null}

      <p className={styles.srOnly} aria-live="polite">
        {message}
      </p>
    </div>
  );
}

const capitalise = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
```

### coin-counter/coin-counter.module.css

```css
/* Coin counter (coin-counter.tsx): the wallet readout alone on the lab's plain stage, with a row
   of gems to pick up and a Spend chip under it.

   The readout is drawn as a game HUD draws it, but on its own dark plate (a pill), so the cream
   figures read on a white stage as well as a near-black one; the change under it ("+20") sits on
   a smaller plate of its own. Its sizes are the HUD's (25 × 45 gems 5 px apart, 32 px figures)
   times --k: 1.4 on a roomy stage, 1.15 on a narrow one (a container query).

   Colours are the tokens below, light-dark(light, dark) where the two differ: the stage and the
   chips follow the color-scheme the component inherits, or the one its colorScheme prop sets
   (data-color-scheme). The plate, the cream figures and the gems are game art: the same on both,
   the plate's edge a step lighter at night so it parts from the stage.

   Crisp throughout: SVG and text, no raster, no blur on any shape. The only soft things are the
   glow that flares behind the icon and the figures' shadow, layers behind sharp shapes. */

.root {
  /* The stage, and its edge. */
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  /* The readout's plate, its edge and its lift. */
  --plate: rgb(20 24 22 / 0.9);
  --plate-edge: rgb(255 255 255 / 0.06);
  --plate-shadow: 0 6px 18px rgb(0 0 0 / 0.16), 0 1px 3px rgb(0 0 0 / 0.18);
  /* The readout: cream figures, their shadow, and the warning red. */
  --figure: #e8e1d0;
  --figure-shadow: rgb(0 0 0 / 0.5);
  --warn: #ff5a48;
  --minus: #ff8f6e;
  /* The lab chips under it: their fill, hover, words, the price, and the focus ring. */
  --chip: #f3f3f3;
  --chip-edge: rgb(0 0 0 / 0.05);
  --chip-hover: #e8e8ea;
  --chip-ink: #17191d;
  --chip-muted: #5d6068;
  --price: #c2410c;
  --focus: #387bff;
  /* Each gem's glow: its brightest facet. */
  --glow-green: #7aff23;
  --glow-blue: #28f0ff;
  --glow-red: #ff8000;
  --glow-purple: #ffa6fe;
  --glow-silver: #ffffff;
  --glow-gold: #fdffad;
  position: relative;
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* Room under the readout for its change label and a picked gem popping in. */
  gap: 80px;
  width: 100%;
  aspect-ratio: 16 / 10;
  min-height: 380px;
  padding: 24px 16px;
  box-sizing: border-box;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--chip-ink);
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    system-ui,
    -apple-system,
    'Segoe UI',
    sans-serif;
  -webkit-tap-highlight-color: transparent;
  isolation: isolate;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #121214);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --plate: light-dark(rgb(20 24 22 / 0.9), #1c201e);
    --plate-edge: light-dark(rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.1));
    --chip: light-dark(#f3f3f3, #232326);
    --chip-edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --chip-hover: light-dark(#e8e8ea, #2d2d31);
    --chip-ink: light-dark(#17191d, #ececef);
    --chip-muted: light-dark(#5d6068, #a3a5ad);
    --price: light-dark(#c2410c, #ff8f6e);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The readout: the icon and the amount on a dark pill, and the change under it. */
.hud {
  --k: 1.4;
  position: relative;
  display: flex;
  align-items: center;
  /* 13 px, and the 1's own side bearing, make the 16-17 px between the icon and the figures. */
  gap: calc(13px * var(--k));
  padding: calc(10px * var(--k)) calc(20px * var(--k)) calc(10px * var(--k)) calc(16px * var(--k));
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    var(--plate-shadow);
  transition:
    opacity 220ms cubic-bezier(0.2, 0.8, 0.3, 1),
    transform 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.hud[data-shown='false'] {
  opacity: 0;
  transform: translateY(-10px);
  transition:
    opacity 320ms ease-in,
    transform 320ms ease-in;
}
.hud[data-land='green'] {
  --land: var(--glow-green);
}
.hud[data-land='blue'] {
  --land: var(--glow-blue);
}
.hud[data-land='red'] {
  --land: var(--glow-red);
}
.hud[data-land='purple'] {
  --land: var(--glow-purple);
}
.hud[data-land='silver'] {
  --land: var(--glow-silver);
}
.hud[data-land='gold'] {
  --land: var(--glow-gold);
}

.icon {
  position: relative;
  display: flex;
  gap: calc(5px * var(--k));
}
.glow {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(84px * var(--k));
  height: calc(84px * var(--k));
  margin: calc(-42px * var(--k)) 0 0 calc(-42px * var(--k));
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--land, #fff), transparent 75%);
  opacity: 0;
  pointer-events: none;
}

/* Every gem is drawn 25 × 45 (the lab sizes bare SVGs otherwise), times --k in the readout, with a
   hairline of shadow that outlines its dark facets on either stage. */
.gem {
  display: block;
  width: 25px;
  height: 45px;
  flex: none;
  overflow: visible;
  filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.3));
}
.hud .gem {
  width: calc(25px * var(--k));
  height: calc(45px * var(--k));
}
.gem[data-color='green'] {
  --tl: #7aff23;
  --tr: #072e0f;
  --left: #017f0c;
  --right: #01520e;
  --table: #00ad00;
  --bl: #09330e;
  --br: #00980a;
}
.gem[data-color='blue'] {
  --tl: #28f0ff;
  --tr: #181f46;
  --left: #204f89;
  --right: #222b55;
  --table: #235cbb;
  --bl: #1a2251;
  --br: #3251a8;
}
.gem[data-color='red'] {
  --tl: #ff8000;
  --tr: #3e161a;
  --left: #a62c28;
  --right: #5e2526;
  --table: #d81b1d;
  --bl: #3e1e1d;
  --br: #ab3639;
}
.gem[data-color='purple'] {
  --tl: #ffa6fe;
  --tr: #2b122f;
  --left: #812785;
  --right: #4f2256;
  --table: #b723b9;
  --bl: #3a153d;
  --br: #942fca;
}
.gem[data-color='silver'] {
  --tl: #fffeff;
  --tr: #2d2c3d;
  --left: #858278;
  --right: #4d4f51;
  --table: #aeaba1;
  --bl: #34323c;
  --br: #8a969c;
}
.gem[data-color='gold'] {
  --tl: #fdffad;
  --tr: #373516;
  --left: #8e7917;
  --right: #4e4c24;
  --table: #baa000;
  --bl: #383713;
  --br: #928d4a;
}

/* A picked gem: pops in under the icon, overshoots, settles, then flies up into it. */
.flight {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
  animation: flight 470ms both;
}
@keyframes flight {
  0% {
    transform: translate(calc(-6px * var(--k)), calc(66px * var(--k))) scale(0.3);
    opacity: 0;
    animation-timing-function: cubic-bezier(0.2, 0.9, 0.3, 1.2);
  }
  30% {
    transform: translate(calc(-6px * var(--k)), calc(52px * var(--k))) scale(1.15);
    opacity: 1;
    animation-timing-function: ease-in-out;
  }
  44% {
    transform: translate(calc(-6px * var(--k)), calc(55px * var(--k))) scale(1);
    opacity: 1;
    animation-timing-function: cubic-bezier(0.5, 0, 0.8, 0.5);
  }
  100% {
    transform: translate(calc(15px * var(--k)), 0) scale(0.55);
    opacity: 0;
  }
}

/* The amount: one window per digit, each showing one figure of its strip. */
.amount {
  display: flex;
  font-size: calc(32px * var(--k));
  line-height: calc(40px * var(--k));
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  color: var(--figure);
  text-shadow:
    0 1px 2px var(--figure-shadow),
    0 0 14px rgb(0 0 0 / 0.18);
  transition: color 120ms ease-out;
}
.hud[data-warn='true'] .amount {
  color: var(--warn);
}
.place {
  display: flex;
}
.window {
  display: block;
  width: 0.57em;
  height: calc(40px * var(--k));
  overflow: hidden;
  overflow: clip;
  overflow-clip-margin: 4px;
  mask-image: linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent);
  transition:
    width 180ms ease-out,
    opacity 180ms ease-out;
}
.strip {
  display: flex;
  flex-direction: column;
  will-change: transform;
}
.strip > span {
  display: block;
  height: calc(40px * var(--k));
  text-align: center;
}
.comma {
  display: block;
  width: 0.3em;
  transition:
    width 180ms ease-out,
    opacity 180ms ease-out;
}
.window[data-leading='true'],
.comma[data-leading='true'] {
  width: 0;
  opacity: 0;
}

/* The change: "+20" under the readout on a small plate of its own, glowing in the gem's colour;
   "−500" in warm red. */
.change {
  position: absolute;
  right: calc(12px * var(--k));
  top: 100%;
  margin-top: calc(8px * var(--k));
  padding: 0 calc(10px * var(--k));
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    var(--plate-shadow);
  font-size: calc(18px * var(--k));
  line-height: calc(28px * var(--k));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--figure);
  text-shadow: 0 0 10px var(--land, transparent);
  opacity: 0;
  transform-origin: 100% 50%;
  transition:
    opacity 360ms ease-in,
    translate 360ms ease-in;
  translate: 0 -6px;
}
.change:empty {
  display: none;
}
.change[data-sign='minus'] {
  color: var(--minus);
  text-shadow: none;
}
.change[data-on='true'] {
  opacity: 1;
  translate: 0 0;
  transition: opacity 120ms ease-out;
}

/* The gem row and the Spend button: small neutral lab chips under the readout. */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 10px;
}
.gems {
  display: flex;
  gap: 2px;
  padding: 4px;
  border-radius: 14px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-edge);
}
.pick {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  width: 42px;
  padding: 8px 0 6px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: var(--chip-muted);
  font: inherit;
  font-size: 12px;
  line-height: 14px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition:
    background-color 140ms ease-out,
    transform 120ms ease-out;
}
.pick .gem {
  transition:
    transform 160ms cubic-bezier(0.3, 1.4, 0.5, 1),
    filter 160ms ease-out;
}
.pick:active,
.pick[data-pressed='true'] {
  transform: scale(0.92);
}
.pick:focus-visible,
.spend:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
@media (hover: hover) {
  .pick:hover {
    background: var(--chip-hover);
    color: var(--chip-ink);
  }
  .pick:hover .gem {
    transform: translateY(-2px);
  }
}
/* Pressed, the gem glows: a soft layer of its colour behind the sharp facets. */
.pick[data-pressed='true'] .gem,
.pick:active .gem {
  filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.3)) drop-shadow(0 0 6px var(--glow, #fff));
}
.pick .gem[data-color='green'] {
  --glow: var(--glow-green);
}
.pick .gem[data-color='blue'] {
  --glow: var(--glow-blue);
}
.pick .gem[data-color='red'] {
  --glow: var(--glow-red);
}
.pick .gem[data-color='purple'] {
  --glow: var(--glow-purple);
}
.pick .gem[data-color='silver'] {
  --glow: var(--glow-silver);
}
.pick .gem[data-color='gold'] {
  --glow: var(--glow-gold);
}
.worth {
  display: block;
}
.spend {
  height: 36px;
  padding: 0 14px;
  border: 0;
  border-radius: 18px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-edge);
  color: var(--chip-ink);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition:
    background-color 140ms ease-out,
    transform 120ms ease-out;
}
.price {
  font-variant-numeric: tabular-nums;
  color: var(--price);
}
@media (hover: hover) {
  .spend:hover {
    background: var(--chip-hover);
  }
}
.spend:active,
.spend[data-pressed='true'] {
  transform: scale(0.95);
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* On a narrow stage the readout is drawn a little smaller, so seven digits still fit. */
@container (max-width: 440px) {
  .hud {
    --k: 1.15;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hud,
  .hud[data-shown='false'],
  .window,
  .comma,
  .pick,
  .pick .gem,
  .spend {
    transition-property: opacity, color, background-color;
  }
  .hud[data-shown='false'] {
    transform: none;
  }
  .change,
  .change[data-on='true'] {
    translate: none;
  }
  .flight {
    display: none;
  }
}
```

### coin-counter/gem.tsx

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

import styles from './coin-counter.module.css';

/*
 * A gem: a tall six-sided crystal cut into seven flat facets, lit from the top left. The drawing is
 * 25 × 45 units; its colours come from the stylesheet (one set of facet tokens per colour, picked by
 * data-color), so one drawing serves all six gems. Each facet is stroked in its own colour, a
 * hair wide, so no seam of what is behind shows between two facets.
 */

export type GemColor = 'green' | 'blue' | 'red' | 'purple' | 'silver' | 'gold';

/** The facets, from the brightest to the darkest side: each a polygon in the 25 × 45 box. */
const FACETS: [string, string][] = [
  // The flat top, lit.
  ['tl', '12.5,0 0,11 6,14.5 12.5,9.5'],
  ['tr', '12.5,0 12.5,9.5 19,14.5 25,11'],
  // The sides.
  ['left', '0,11 6,14.5 6,30.5 0,34'],
  ['right', '25,11 19,14.5 19,30.5 25,34'],
  // The table, the big face in the middle.
  ['table', '12.5,9.5 19,14.5 19,30.5 12.5,35.5 6,30.5 6,14.5'],
  // The bottom.
  ['bl', '0,34 6,30.5 12.5,35.5 12.5,45'],
  ['br', '25,34 12.5,45 12.5,35.5 19,30.5'],
];

export function Gem({ color, className = '' }: { color: GemColor; className?: string }) {
  return (
    <svg
      className={`${styles.gem} ${className}`}
      data-color={color}
      viewBox="0 0 25 45"
      aria-hidden="true"
      focusable="false"
    >
      {FACETS.map(([name, points]) => (
        <polygon
          key={name}
          points={points}
          style={{ fill: `var(--${name})`, stroke: `var(--${name})` }}
          strokeWidth="0.35"
          strokeLinejoin="round"
        />
      ))}
    </svg>
  );
}
```

### coin-counter/use-autoplay.ts

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

'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. `reduced` also says whether the reader asks for
 * reduced motion, so the component can skip its own animations too.
 */
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, reduced, playing: visible && !reduced && !taken };
}
```