# Loading state

> A pixel-grid loader with a shimmering status word and an elapsed timer, in four styles.

What an assistant shows while it works: a 3 × 3 grid of pixels pulses, the status word shimmers and a monospaced timer counts the seconds. A small switch picks the loader: Drive, a chevron wave of square pixels; Dots, the same wave in dots; Orbit, a light running round the edge; and Surfer, which adds a little endless-runner clip that pops in underneath.

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

Category: AI.

## Anatomy

1. **Stage**: A quiet grey panel with the loader in its middle. Every colour is a light-dark() token, so it follows the page or the `colorScheme` prop.
2. **Pixel grid**: Three by three pixels whose staggered delays draw the pattern: a chevron wave, or a light running round the edge.
3. **Pixel**: Fades from 15% to full and back once a lap. Only its opacity changes, so the compositor plays it.
4. **Status word**: The live region: a screen reader hears it when it changes. A highlight sweeps through it every 1.4 s. Keep it to a word or two.
5. **Timer**: Seconds in tenths, monospaced so it never jitters. It counts from `since`, so it stays right after a reload.
6. **Runner clip**: The Surfer loader only: a 16:9 endless runner drawn on a canvas, popping in from its top left in 200 ms.
7. **Switch**: A radio group of the four loaders: one tab stop, the arrows move and pick. Hide it with `switcher={false}`.
8. **Picked option**: Raised on a white pill with a hairline ring by day, a lighter grey with a drop shadow at night.

## When to use

- An assistant or agent working on an answer for an unknown time: a few seconds to a few minutes.
- Work where the elapsed time matters to the reader, so they can judge whether to wait or stop it.
- A chat or a console line, where the loader takes one quiet row and the reply replaces it.
- Long waits that need a little life: the Surfer loader adds a small clip to watch under the word.

## When not to use

- Progress you can measure, like an upload or an import. Show a bar or a count of what is left.
- Waits under a second: the timer never passes 0.9s and the shimmer barely starts. Show nothing.
- A loader inside a button or a field. Use a spinner at text size and keep the button’s label.
- Several jobs at once: one shimmering line each competes for attention. Use one list with a status per row.
- Telling people what went wrong: the loader only knows working. Replace it with the error in words.

## Installation

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

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

export function Reply({ startedAt }: { startedAt: number }) {
  return <LoadingState label="Thinking" since={startedAt} switcher={false} />;
}
```

## States

- **Drive**: Square pixels lit in a chevron wave, left to right, 650 ms a lap. Held still here.
- **Dots**: The same wave in round dots, for a softer line.
- **Orbit**: A light runs clockwise round the eight outer pixels every 950 ms; the middle one rests.
- **Surfer**: Its own word, and the runner clip under it. Locked, the clip shows its first frame.
- **Without the switch**: `switcher={false}`: just the line, as it sits in a chat while an answer is written.
- **Long wait**: `since` an earlier time: the timer counts on in seconds, here past two minutes.
- **Dark**: `colorScheme="dark"`: a near-black stage, the pixels and the highlight almost white.

## How it works

- Every pixel plays the same keyframes, dim to lit in the first 18% of its lap, held to 42%, dim again by 62%, each from its own delay. The delays draw the pattern: a chevron wave (650 ms laps, 90 ms apart) for Drive and Dots, a clockwise run round the eight outer pixels (950 ms laps, 110 ms apart) for Orbit, whose middle pixel rests at 7%.
- Only opacity animates, on nine 4 px boxes, so the compositor plays it all. Each lap starts 400 ms in, so a held grid already shows its pattern.
- The word is clipped to a background twice its width, muted at 35%, bright at 50%, muted again at 65%. It slides from 150% to −50% in 1.4 s, linear, so a highlight sweeps through the word left to right.
- The timer reads the clock every 100 ms and shows tenths of a second, counting from `since` or from when the loader appeared, so it is right whenever it is looked at.
- The Surfer clip is a canvas, not a video: one perspective projection (x = K·X / z, y = horizon + K·(1 − Y) / z), and every polygon painted far to near. The tracks, arches and bushes repeat along z; the runner, its coins and the trains follow a 9.6 s script of six beats, so each frame is a pure function of one clock and a paused clip resumes exactly where it stopped.
- The clip pops in from its top-left corner, opacity 0 → 1 and scale 0.95 → 1 in 200 ms on cubic-bezier(0.16, 1, 0.3, 1).
- Off screen or in a hidden tab, the pixels, the sweep and the clip pause (animation-play-state and no frames drawn).
- Every colour is a light-dark() token on the root, so the loader follows the page’s color-scheme, or the colorScheme prop. The picked button sits on a white pill with a grey ring by day, and on a slightly lighter pill with a faint light ring and a drop shadow at night.

## API reference

- **variant** (`'drive' | 'dots' | 'orbit' | 'surfer'`): The loader shown. With it the component is controlled; pair it with onVariantChange.
- **defaultVariant** (`'drive' | 'dots' | 'orbit' | 'surfer'`, default `'drive'`): The loader shown first, when uncontrolled.
- **onVariantChange** (`(variant: LoaderVariant) => void`): Called with the loader picked in the switch.
- **label** (`string`, default `'Churning'`): The status word.
- **surferLabel** (`string`, default `'Rail surfing'`): The Surfer loader’s status word.
- **since** (`number`): When the work began, as a Date.now() timestamp; without it, the timer counts from when the loader appeared.
- **elapsed** (`number`): A fixed timer reading, in ms: the timer shows it and holds still instead of counting.
- **switcher** (`boolean`, default `true`): Shows the switch between the four loaders at the foot of the stage.
- **autoplay** (`boolean`, default `true`): While on screen, the pixels pulse, the word shimmers and the clip runs; off, they hold still (the timer still counts).
- **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.

## Accessibility

- The status word is a polite live region (role="status"): it is announced when it changes, never on every tick of the timer. The timer’s tenths are hidden from screen readers, which read whole seconds instead.
- The switch is a radio group named “Loader style”: one tab stop, arrow keys, Home and End move and pick, with a visible focus ring.
- The pixel grid and the clip are decorative (aria-hidden).
- Under reduced motion nothing moves: the grid is drawn still, part way through its laps, the word is plain, the clip shows one still frame and pops in without animation.

## Source

### loading-state/loading-state.tsx

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

'use client';

import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { RunnerClip } from './runner-clip';
import { useAutoplay } from './use-autoplay';
import styles from './loading-state.module.css';

/*
 * Loading state: what an assistant shows while it works. A tiny 3 × 3 grid of pixels pulses, a
 * status word shimmers, and a monospaced timer counts the seconds since the work began. Four
 * styles of loader, picked from a segmented switch under it:
 *
 * - Drive: square pixels light up in a chevron wave, left to right, 650 ms a lap.
 * - Dots: the same wave in round dots.
 * - Orbit: a light runs clockwise round the eight outer pixels, 950 ms a lap, its tail fading
 *   behind it; the middle pixel stays dim.
 * - Surfer: the Drive grid with a different word, and under it a small clip of an endless runner
 *   that pops in (drawn on a canvas, runner-clip.tsx).
 *
 * How it moves
 * - Each pixel plays the same CSS keyframes (dim → lit for a quarter of the lap → dim), started
 *   at its own delay; the delays draw the pattern. Only opacity changes, so the compositor plays
 *   them all.
 * - The word is clipped to a background three times its colours wide: muted, bright in the middle,
 *   muted again. The background slides across in 1.4 s, so a highlight sweeps through the word.
 * - The timer reads the clock every 100 ms and shows tenths of a second. It counts from `since`
 *   (or from when the loader appeared), so it is right whenever it is looked at; with `elapsed` it
 *   shows that reading and holds still.
 * - Off screen, in a hidden tab or with autoplay off, the pixels, the sweep and the clip hold
 *   still. Under reduced motion they never move: the grid shows its pattern half lit.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type LoaderVariant = 'drive' | 'dots' | 'orbit' | 'surfer';

const VARIANTS: { id: LoaderVariant; name: string }[] = [
  { id: 'drive', name: 'Drive' },
  { id: 'dots', name: 'Dots' },
  { id: 'orbit', name: 'Orbit' },
  { id: 'surfer', name: 'Surfer' },
];

/**
 * Each loader's pixels, row by row: the delay (ms) each starts its lap at, or null for a pixel
 * that stays dim; the lap's length; and whether the pixels are dots.
 */
const WAVE = [90, 180, 270, 0, 90, 180, 90, 180, 270];
const PATTERNS: Record<LoaderVariant, { delays: (number | null)[]; lap: number; round?: boolean }> =
  {
    drive: { delays: WAVE, lap: 650 },
    dots: { delays: WAVE, lap: 650, round: true },
    // Clockwise from the top left: 0, 110 … 770 ms; the middle one rests.
    orbit: { delays: [0, 110, 220, 770, null, 330, 660, 550, 440], lap: 950 },
    surfer: { delays: WAVE, lap: 650 },
  };

/** Seconds since `start`, in tenths: 14.7s. */
/**
 * How lit a pixel is HELD ms into its lap: the keyframes' curve (dim, up by 18%, lit to 42%, down
 * by 62%), eased. Under reduced motion the grid is drawn still at this moment of its laps.
 */
const HELD = 400;
function heldOpacity(delay: number, lap: number) {
  const p = ((((HELD - delay) % lap) + lap) % lap) / lap;
  const smooth = (t: number) => t * t * (3 - 2 * t);
  const lit =
    p < 0.18 ? smooth(p / 0.18) : p < 0.42 ? 1 : p < 0.62 ? 1 - smooth((p - 0.42) / 0.2) : 0;
  return (0.15 + 0.85 * lit).toFixed(2);
}

const format = (ms: number) => `${(Math.max(0, ms) / 1000).toFixed(1)}s`;

export function LoadingState({
  variant: controlled,
  defaultVariant = 'drive',
  onVariantChange,
  label = 'Churning',
  surferLabel = 'Rail surfing',
  since,
  elapsed: fixed,
  switcher = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The loader shown; with it, the component is controlled (pair it with onVariantChange). */
  variant?: LoaderVariant;
  /** The loader shown first, when uncontrolled. */
  defaultVariant?: LoaderVariant;
  /** Called with the loader picked in the switch. */
  onVariantChange?: (variant: LoaderVariant) => void;
  /** The status word. */
  label?: string;
  /** The Surfer loader's status word. */
  surferLabel?: string;
  /** When the work began (a Date.now() timestamp); without it, when the loader appeared. */
  since?: number;
  /** A fixed timer reading, in ms: the timer then shows it and holds still instead of counting. */
  elapsed?: number;
  /** Shows the switch between the four loaders. */
  switcher?: boolean;
  /** While on screen, the pixels pulse, the word shimmers and the clip runs. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing } = useAutoplay<HTMLDivElement>();
  const [own, setOwn] = useState(defaultVariant);
  const variant = controlled ?? own;
  const pattern = PATTERNS[variant];
  const word = variant === 'surfer' ? surferLabel : label;
  const moving = autoplay && playing;

  // The timer: from `since`, or from the first render.
  const [mounted] = useState(() => Date.now());
  const start = since ?? mounted;
  const [elapsed, setElapsed] = useState(0);
  useEffect(() => {
    if (fixed !== undefined) return;
    const update = () => setElapsed(Date.now() - start);
    update();
    const id = window.setInterval(update, 100);
    return () => window.clearInterval(id);
  }, [start, fixed]);
  const shown = fixed ?? elapsed;

  const pick = (next: LoaderVariant) => {
    if (controlled === undefined) setOwn(next);
    onVariantChange?.(next);
  };

  // The switch is a radio group: one tab stop, arrows move and pick.
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const index = VARIANTS.findIndex((v) => v.id === variant);
    const moves: Record<string, number> = {
      ArrowRight: index + 1,
      ArrowDown: index + 1,
      ArrowLeft: index - 1,
      ArrowUp: index - 1,
      Home: 0,
      End: VARIANTS.length - 1,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    const next = (moves[event.key] + VARIANTS.length) % VARIANTS.length;
    pick(VARIANTS[next].id);
    buttons.current[next]?.focus();
  };
  const groupLabel = useId();

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-moving={moving || undefined}
    >
      <div className={styles.stage}>
        <div className={styles.loader} data-part="loader" data-variant={variant}>
          <div className={styles.line}>
            <span
              className={styles.grid}
              data-part="grid"
              aria-hidden="true"
              data-round={pattern.round || undefined}
            >
              {pattern.delays.map((delay, index) => (
                <span
                  // Keyed by loader too, so a new pattern starts its laps afresh.
                  key={`${variant}-${index}`}
                  className={delay === null ? styles.rest : styles.pixel}
                  data-part={delay === null ? 'rest' : 'pixel'}
                  style={
                    delay === null
                      ? undefined
                      : ({
                          '--delay': `${delay}ms`,
                          '--lap': `${pattern.lap}ms`,
                          '--held': heldOpacity(delay, pattern.lap),
                        } as CSSProperties)
                  }
                />
              ))}
            </span>
            {/* The word is the live region: it is announced when it changes, never the timer. */}
            <span className={styles.word} data-part="word" role="status">
              {word}
            </span>
            <span className={styles.timer} data-part="timer">
              <span aria-hidden="true">{format(shown)}</span>
              <span className={styles.hidden}>, {Math.floor(shown / 1000)} seconds elapsed</span>
            </span>
          </div>
          {variant === 'surfer' && (
            <div className={styles.clip} data-part="clip">
              <RunnerClip playing={moving} className={styles.canvas} />
            </div>
          )}
        </div>
      </div>
      {switcher && (
        <div
          className={styles.switch}
          data-part="switch"
          role="radiogroup"
          aria-labelledby={groupLabel}
          onKeyDown={onKeyDown}
        >
          <span id={groupLabel} className={styles.hidden}>
            Loader style
          </span>
          {VARIANTS.map((v, index) => (
            <button
              key={v.id}
              ref={(element) => {
                buttons.current[index] = element;
              }}
              type="button"
              role="radio"
              aria-checked={v.id === variant}
              tabIndex={v.id === variant ? 0 : -1}
              className={styles.option}
              data-part="option"
              onClick={() => pick(v.id)}
            >
              {v.name}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}
```

### loading-state/runner-clip.tsx

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

'use client';

import { useEffect, useRef } from 'react';

/*
 * The Surfer loader's little clip: an endless runner, drawn on a canvas instead of played from a
 * video file. A runner dashes down three tracks between two arched viaduct walls, hops from lane
 * to lane, jumps now and then, picks up coins, and lets the trains pass in the other lanes.
 *
 * - One camera, one projection: a point X across, Y up and z ahead of the camera is drawn at
 *   (CX + K·X / z, HORIZON + K·(1 − Y) / z). Everything is a polygon of projected points, painted
 *   far to near.
 * - It is all a function of one clock: the tracks, arches and bushes repeat along z; the runner,
 *   coins and trains follow a 9.6 s script of six 1.6 s beats. So a frame never depends on the
 *   one before it, and a paused clip resumes exactly where it stopped.
 * - It draws only while `playing`; otherwise it holds its last frame (or, the first time, draws
 *   a still one).
 */

/** The clip's own size in CSS px (it is drawn at the device's pixel ratio, up to 2). */
const W = 224;
const H = 126;
const CX = W / 2;
const HORIZON = 40;
/** Scale of the projection: the ground under the camera (z = 1) is the clip's bottom edge. */
const K = H - HORIZON;
/** The world rushes past at SPEED units a second; trains come on at their own speed too. */
const SPEED = 6;
const TRAIN_SPEED = 4;
const NEAR = 0.32;
const FAR = 46;
/** The three lanes, across. */
const LANE = 0.55;
/** The inner faces of the walls, and how tall they are. */
const WALL = 1.28;
const WALL_TOP = 1.9;
/** Where the runner runs, ahead of the camera. */
const RUNNER_Z = 1.9;
/** The script: a beat, and the lane of each of its six beats. */
const BEAT = 1.6;
const LANES = [0, -1, -1, 0, 1, 1];
/** The beats on which the runner jumps, halfway through. */
const JUMPS = new Set([1, 4]);

const colors = {
  skyTop: '#3d9be0',
  skyLow: '#bfe4f6',
  hills: '#5aa35a',
  ground: '#6e5b4b',
  gravel: '#7d6a58',
  sleeper: '#9a8672',
  rail: '#3a332e',
  railShine: '#b9b2aa',
  wall: '#e04a1e',
  wallShade: '#b8360f',
  wallTop: '#f0703f',
  arch: '#7c2210',
  archGreen: '#3d7d2c',
  bush: '#4f9a2c',
  bushLight: '#74bd3f',
  coin: '#f7c531',
  coinEdge: '#c48a12',
  trainBody: '#f0bf2c',
  trainSide: '#d9a51f',
  trainStripe: '#d4462a',
  trainRoof: '#3d4f8f',
  trainGlass: '#a6d6f2',
  skin: '#f1c7a1',
  hoodie: '#e9e6e2',
  cap: '#d4402c',
  legs: '#33416b',
  shoes: '#f4f4f4',
  shadow: 'rgb(0 0 0 / 0.28)',
  hud: '#ffffff',
  hudShade: 'rgb(0 0 0 / 0.45)',
  hudChip: '#2f6fd1',
};

type Point = [number, number];

const project = (x: number, y: number, z: number): Point => [
  CX + (K * x) / z,
  HORIZON + (K * (1 - y)) / z,
];

const ease = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
const clamp = (t: number) => Math.min(1, Math.max(0, t));
const mod = (a: number, n: number) => ((a % n) + n) % n;

function polygon(context: CanvasRenderingContext2D, points: Point[], fill: string) {
  context.beginPath();
  context.moveTo(points[0][0], points[0][1]);
  for (const [x, y] of points.slice(1)) context.lineTo(x, y);
  context.closePath();
  context.fillStyle = fill;
  context.fill();
}

/** Where the runner is across (in world units), and how high it is off the ground, at `t` s. */
function runnerAt(t: number) {
  const n = Math.floor(t / BEAT);
  const into = t - n * BEAT;
  const lane = LANES[mod(n, 6)];
  const previous = LANES[mod(n - 1, 6)];
  const x = (previous + (lane - previous) * ease(clamp(into / 0.26))) * LANE;
  const jump = JUMPS.has(mod(n, 6)) ? clamp((into - 0.45) / 0.62) : 0;
  return { x, y: jump > 0 && jump < 1 ? 0.42 * Math.sin(Math.PI * jump) : 0 };
}

/** The whole frame at `t` s. */
function draw(context: CanvasRenderingContext2D, t: number) {
  const travel = t * SPEED;

  // Sky, and a band of far hills along the horizon.
  const sky = context.createLinearGradient(0, 0, 0, HORIZON);
  sky.addColorStop(0, colors.skyTop);
  sky.addColorStop(1, colors.skyLow);
  context.fillStyle = sky;
  context.fillRect(0, 0, W, HORIZON + 1);
  context.fillStyle = colors.hills;
  context.beginPath();
  context.moveTo(0, HORIZON + 1);
  for (let x = 0; x <= W; x += 8) context.lineTo(x, HORIZON - 3 - 2.5 * Math.sin(x * 0.09 + 1));
  context.lineTo(W, HORIZON + 1);
  context.fill();

  // The ground.
  context.fillStyle = colors.ground;
  context.fillRect(0, HORIZON, W, H - HORIZON);

  // Gravel beds under each lane.
  for (const lane of [-1, 0, 1]) {
    const x = lane * LANE;
    polygon(
      context,
      [
        project(x - 0.36, 0, NEAR),
        project(x + 0.36, 0, NEAR),
        project(x + 0.36, 0, FAR),
        project(x - 0.36, 0, FAR),
      ],
      colors.gravel,
    );
  }

  // Sleepers, far to near, every 0.55 units.
  const step = 0.55;
  for (let z = FAR - mod(FAR + travel, step); z > NEAR; z -= step) {
    for (const lane of [-1, 0, 1]) {
      const x = lane * LANE;
      polygon(
        context,
        [
          project(x - 0.3, 0, z),
          project(x + 0.3, 0, z),
          project(x + 0.3, 0, z + 0.16),
          project(x - 0.3, 0, z + 0.16),
        ],
        colors.sleeper,
      );
    }
  }

  // Rails.
  for (const lane of [-1, 0, 1]) {
    for (const side of [-0.2, 0.2]) {
      const x = lane * LANE + side;
      polygon(
        context,
        [
          project(x - 0.025, 0.03, NEAR),
          project(x + 0.025, 0.03, NEAR),
          project(x + 0.025, 0.03, FAR),
          project(x - 0.025, 0.03, FAR),
        ],
        colors.rail,
      );
    }
  }

  // The walls, with their arches running past.
  for (const side of [-1, 1]) {
    const x = side * WALL;
    polygon(
      context,
      [
        project(x, 0, NEAR),
        project(x, WALL_TOP, NEAR),
        project(x, WALL_TOP, FAR),
        project(x, 0, FAR),
      ],
      colors.wall,
    );
    polygon(
      context,
      [
        project(x, WALL_TOP - 0.12, NEAR),
        project(x, WALL_TOP, NEAR),
        project(x, WALL_TOP, FAR),
        project(x, WALL_TOP - 0.12, FAR),
      ],
      colors.wallTop,
    );
    const period = 2.4;
    for (let z = FAR - mod(FAR + travel + side * 0.6, period); z > NEAR - 1.6; z -= period) {
      const z1 = Math.max(z, NEAR);
      const z2 = z + 1.6;
      if (z2 <= NEAR) continue;
      const a = project(x, 0.12, z1);
      const b = project(x, 0.85, z1);
      const c = project(x, 0.85, z2);
      const d = project(x, 0.12, z2);
      const top = project(x, 1.45, (z1 + z2) / 2);
      context.beginPath();
      context.moveTo(a[0], a[1]);
      context.lineTo(b[0], b[1]);
      context.quadraticCurveTo(top[0], top[1] - (b[1] - top[1]) * 0.35, c[0], c[1]);
      context.lineTo(d[0], d[1]);
      context.closePath();
      context.fillStyle = colors.arch;
      context.fill();
      // A glimpse of green through the lower half of the arch.
      polygon(
        context,
        [project(x, 0.12, z1), project(x, 0.42, z1), project(x, 0.42, z2), project(x, 0.12, z2)],
        colors.archGreen,
      );
    }
    // A shaded strip where the wall meets the ground.
    polygon(
      context,
      [project(x, 0, NEAR), project(x, 0.12, NEAR), project(x, 0.12, FAR), project(x, 0, FAR)],
      colors.wallShade,
    );
  }

  // Bushes along the foot of each wall.
  for (const side of [-1, 1]) {
    const period = 3.1;
    for (let z = FAR - mod(FAR + travel + side * 1.3, period); z > 0.6; z -= period) {
      const [bx, by] = project(side * (WALL - 0.22), 0, z);
      const r = (0.3 * K) / z;
      context.fillStyle = colors.bush;
      context.beginPath();
      context.arc(bx, by - r * 0.7, r, 0, Math.PI * 2);
      context.fill();
      context.fillStyle = colors.bushLight;
      context.beginPath();
      context.arc(bx - side * r * 0.25, by - r * 0.95, r * 0.5, 0, Math.PI * 2);
      context.fill();
    }
  }

  // Trains, coins and the runner, painted far to near.
  type Item = { z: number; paint: () => void };
  const items: Item[] = [];

  // A train comes down the lane the runner keeps clear of for three beats: lane 1 over beats
  // 0–2 of the script, lane −1 over beats 3–5.
  const cycle = Math.floor(t / (BEAT * 6));
  for (const [k, lane] of [
    [0, 1],
    [3, -1],
  ] as const) {
    for (const c of [cycle - 1, cycle]) {
      const start = (c * 6 + k) * BEAT;
      const front = FAR - (SPEED + TRAIN_SPEED) * (t - start);
      // None before the clock starts, so the clip never opens on half a train.
      if (start < 0 || front + 5 < NEAR || front > FAR) continue;
      items.push({ z: front, paint: () => paintTrain(context, lane * LANE, front) });
    }
  }

  // Three coins a beat, in the lane the runner will be in, met 0.4, 0.8 and 1.2 s into it.
  const now = Math.floor(t / BEAT);
  for (let n = now - 1; n <= now + 5; n++) {
    const lane = LANES[mod(n, 6)];
    for (const at of [0.4, 0.8, 1.2]) {
      const arrive = n * BEAT + at;
      if (arrive <= t) continue;
      const z = RUNNER_Z + SPEED * (arrive - t);
      if (z > FAR) continue;
      items.push({ z, paint: () => paintCoin(context, lane * LANE, z, t) });
    }
  }
  // The score: every coin met since the start of the clock.
  const collected =
    Math.max(0, Math.floor(t / BEAT)) * 3 +
    [0.4, 0.8, 1.2].filter((at) => t - now * BEAT >= at).length;

  const runner = runnerAt(t);
  items.push({ z: RUNNER_Z, paint: () => paintRunner(context, runner.x, runner.y, t) });

  items.sort((a, b) => b.z - a.z);
  for (const item of items) item.paint();

  paintHud(context, collected, t);
}

function paintTrain(context: CanvasRenderingContext2D, x: number, front: number) {
  const half = 0.33;
  const height = 1.05;
  const back = front + 5;
  const zf = Math.max(front, NEAR);
  // The side the camera can see: the one facing the middle of the track.
  const face = x < 0 ? x + half : x - half;
  polygon(
    context,
    [
      project(face, 0.05, zf),
      project(face, height, zf),
      project(face, height, back),
      project(face, 0.05, back),
    ],
    colors.trainSide,
  );
  polygon(
    context,
    [
      project(face, 0.3, zf),
      project(face, 0.4, zf),
      project(face, 0.4, back),
      project(face, 0.3, back),
    ],
    colors.trainStripe,
  );
  // Windows along the side.
  for (let w = front + 0.5; w < back - 0.4; w += 0.9) {
    const z1 = Math.max(w, NEAR);
    const z2 = w + 0.5;
    if (z2 <= NEAR) continue;
    polygon(
      context,
      [
        project(face, 0.55, z1),
        project(face, 0.85, z1),
        project(face, 0.85, z2),
        project(face, 0.55, z2),
      ],
      colors.trainGlass,
    );
  }
  // The roof.
  polygon(
    context,
    [
      project(x - half, height, zf),
      project(x + half, height, zf),
      project(x + half, height, back),
      project(x - half, height, back),
    ],
    colors.trainRoof,
  );
  if (front < NEAR) return;
  // The front: body, stripe, windscreen.
  const [l, t] = project(x - half, height, front);
  const [r, b] = project(x + half, 0.05, front);
  context.fillStyle = colors.trainBody;
  context.beginPath();
  context.roundRect(l, t, r - l, b - t, (r - l) * 0.12);
  context.fill();
  const unit = (r - l) / (2 * half);
  context.fillStyle = colors.trainStripe;
  context.fillRect(l, b - unit * 0.4, r - l, unit * 0.1);
  context.fillStyle = colors.trainGlass;
  context.fillRect(l + unit * 0.1, t + unit * 0.12, r - l - unit * 0.2, unit * 0.32);
  context.fillStyle = colors.trainRoof;
  context.fillRect(l, t, r - l, unit * 0.06);
}

function paintCoin(context: CanvasRenderingContext2D, x: number, z: number, t: number) {
  const [cx, cy] = project(x, 0.35, z);
  const r = (0.13 * K) / z;
  const turn = Math.abs(Math.cos(t * 5 + z));
  context.fillStyle = colors.coinEdge;
  context.beginPath();
  context.ellipse(cx, cy, Math.max(r * turn, r * 0.18) + r * 0.12, r * 1.06, 0, 0, Math.PI * 2);
  context.fill();
  context.fillStyle = colors.coin;
  context.beginPath();
  context.ellipse(cx, cy, Math.max(r * turn, r * 0.15), r, 0, 0, Math.PI * 2);
  context.fill();
}

function paintRunner(context: CanvasRenderingContext2D, x: number, lift: number, t: number) {
  const [gx, gy] = project(x, 0, RUNNER_Z);
  const u = K / RUNNER_Z / 10; // px per tenth of a unit
  const stride = Math.sin(t * 14);
  const up = (lift * K) / RUNNER_Z;
  // Its shadow stays on the ground, smaller as it jumps.
  context.fillStyle = colors.shadow;
  context.beginPath();
  context.ellipse(gx, gy, u * (1.6 - lift), u * 0.5, 0, 0, Math.PI * 2);
  context.fill();
  const base = gy - up - Math.abs(stride) * u * 0.25;
  // Legs, swinging; seen from behind, a lifted leg is a shorter one.
  for (const side of [-1, 1]) {
    const swing = lift ? 0.6 : side * stride;
    const length = u * (2.2 - Math.max(0, swing) * 0.9);
    context.fillStyle = colors.legs;
    context.fillRect(gx + side * u * 0.55 - u * 0.4, base - u * 2.3, u * 0.8, length);
    context.fillStyle = colors.shoes;
    context.fillRect(
      gx + side * u * 0.55 - u * 0.45,
      base - u * 2.3 + length - u * 0.35,
      u * 0.9,
      u * 0.45,
    );
  }
  // Torso and arms.
  context.fillStyle = colors.hoodie;
  context.beginPath();
  context.roundRect(gx - u * 1.05, base - u * 4.4, u * 2.1, u * 2.3, u * 0.5);
  context.fill();
  for (const side of [-1, 1]) {
    const swing = -side * stride;
    context.fillRect(
      gx + side * u * 1.05 - u * 0.25,
      base - u * 4.1 - swing * u * 0.3,
      u * 0.5,
      u * 1.6,
    );
  }
  // Head and cap.
  context.fillStyle = colors.skin;
  context.beginPath();
  context.arc(gx, base - u * 5.1, u * 0.85, 0, Math.PI * 2);
  context.fill();
  context.fillStyle = colors.cap;
  context.beginPath();
  context.arc(gx, base - u * 5.3, u * 0.88, Math.PI, 0);
  context.fill();
}

function paintHud(context: CanvasRenderingContext2D, score: number, t: number) {
  context.font = '700 7px ui-monospace, monospace';
  context.textBaseline = 'top';
  context.textAlign = 'right';
  const text = String(score * 10 + Math.floor(t * 12)).padStart(6, '0');
  context.fillStyle = colors.hudShade;
  context.fillText(text, W - 5, 6);
  context.fillStyle = colors.hud;
  context.fillText(text, W - 5.5, 5.5);
  // Coins picked up.
  context.fillStyle = colors.coin;
  context.beginPath();
  context.arc(W - 8, 18, 2.6, 0, Math.PI * 2);
  context.fill();
  context.fillStyle = colors.hud;
  context.fillText(String(score), W - 13, 15);
  // A pause button, top left.
  context.fillStyle = colors.hudChip;
  context.beginPath();
  context.roundRect(5, 5, 9, 9, 2);
  context.fill();
  context.fillStyle = colors.hud;
  context.fillRect(7.8, 7.3, 1.2, 4.4);
  context.fillRect(10, 7.3, 1.2, 4.4);
}

export function RunnerClip({ playing, className }: { playing: boolean; className?: string }) {
  const canvas = useRef<HTMLCanvasElement>(null);
  /** The clip's own clock, in seconds: it only runs while the clip plays. */
  const clock = useRef(0);

  useEffect(() => {
    const element = canvas.current;
    const context = element?.getContext('2d');
    if (!element || !context) return;
    const ratio = Math.min(2, window.devicePixelRatio || 1);
    element.width = Math.round(W * ratio);
    element.height = Math.round(H * ratio);
    // The canvas may be drawn a little wider or narrower than W; the scene scales with it.
    const paint = () => {
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      draw(context, clock.current);
    };
    paint();
    if (!playing) return;
    let frame = 0;
    let last = performance.now();
    const tick = (now: number) => {
      // A long gap (a frozen tab) is not run through: the clip just carries on.
      clock.current += Math.min(0.1, (now - last) / 1000);
      last = now;
      paint();
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [playing]);

  return <canvas ref={canvas} className={className} aria-hidden="true" />;
}
```

### loading-state/loading-state.module.css

```css
/* Loading state (loading-state.tsx): a quiet grey stage with the loader in its middle and the
   switch between loaders at its foot. At night it is near-black, the pixels and the shimmer's
   highlight almost white; by day it is a pale grey, and they turn near-black.

   Colours are the tokens below, light-dark(light, dark) where the two differ: 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).

   Memory. Everything that moves is opacity, a transform or a background position on a few small
   boxes; the clip is one 224 × 126 canvas, drawn only while it plays. */

.root {
  /* The words and the lit pixels; a step down; the muted grey of the timer and the idle words. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* The stage, its edge, the switch's track and its picked button. */
  --canvas: oklch(96.1% 0.002 247.84);
  --line: oklch(94.6% 0.003 264.542);
  --field: oklch(96.1% 0.001 286.375);
  --surface: oklch(100% 0 0);
  /* The picked button's edge and lift: a ring and a soft glow by day, a faint light ring and a
     drop shadow at night (the layer one scheme doesn't use is transparent). */
  --button-ring: oklch(91.2% 0.005 258.326);
  --button-glow: rgb(0 0 0 / 0.04);
  --button-drop: transparent;
  /* The clip's frame: a ring and a tall stack of soft shadows by day, one deeper shadow at night. */
  --clip-ring: oklch(94.6% 0.003 264.542);
  --clip-soft: 1;
  --clip-deep: transparent;
  --clip-back: oklch(27.2% 0.008 264.435);
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  display: grid;
  width: 100%;
  min-height: 272px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  letter-spacing: -0.14px;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --button-ring: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-glow: light-dark(rgb(0 0 0 / 0.04), transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
    --clip-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.15));
    --clip-soft: light-dark(1, 0);
    --clip-deep: light-dark(transparent, oklch(0% 0 0 / 0.34));
    --clip-back: light-dark(oklch(27.2% 0.008 264.435), oklch(18.2% 0.004 264.459));
    --focus: light-dark(#387bff, #6ea0ff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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 loader sits in the middle of the stage; the switch floats over its foot. */
.stage {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  padding: 12px;
}

.loader {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: fit-content;
  max-width: 100%;
  /* A flex child keeps its content's width by default; let it shrink so a long word ellipsizes. */
  min-width: 0;
}

.line {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

/* The pixels: 3 × 3 of 4px, 1.5px apart. */
.grid {
  display: grid;
  flex-shrink: 0;
  grid-template-columns: repeat(3, 4px);
  gap: 1.5px;
}
.pixel,
.rest {
  width: 4px;
  height: 4px;
  border-radius: 1px;
  background: var(--ink);
}
.grid[data-round] > * {
  border-radius: 50%;
}
.rest {
  opacity: 0.07;
}
.pixel {
  opacity: 0.15;
  /* Laps run from 400ms before their delay: the same pattern, but already under way, so a held
     grid (off screen, reduced motion) still shows it. */
  animation: pixel var(--lap) ease-in-out calc(var(--delay) - 400ms) infinite;
  animation-play-state: paused;
}
@keyframes pixel {
  0% {
    opacity: 0.15;
  }
  18% {
    opacity: 1;
  }
  42% {
    opacity: 1;
  }
  62% {
    opacity: 0.15;
  }
  100% {
    opacity: 0.15;
  }
}

/* The word, its colours clipped to a band twice its width: muted, bright, muted. The band slides
   right to left, so the highlight sweeps through the word left to right. */
.word {
  overflow: hidden;
  font-size: 13px;
  font-weight: 500;
  line-height: 19.5px;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: transparent;
  background-image: linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%);
  background-size: 200% 100%;
  background-position: 150% 50%;
  background-clip: text;
  -webkit-background-clip: text;
  animation: sweep 1.4s linear infinite;
  animation-play-state: paused;
}
@keyframes sweep {
  from {
    background-position: 150% 50%;
  }
  to {
    background-position: -50% 50%;
  }
}

.timer {
  flex-shrink: 0;
  color: var(--ink-3);
  font-family: JetBrains, 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
  font-size: 12px;
  line-height: 18px;
  font-variant-numeric: tabular-nums;
}

.root[data-moving] .pixel,
.root[data-moving] .word {
  animation-play-state: running;
}

/* The Surfer's clip: 224px wide (narrower if it must be), 16:9, popping in from its top left. */
.clip {
  width: 224px;
  max-width: 100%;
  aspect-ratio: 16 / 9;
  margin-top: 8px;
  overflow: hidden;
  border-radius: 10px;
  background: var(--clip-back);
  box-shadow:
    0 0 0 1px var(--clip-ring),
    0 25px 50px rgb(0 0 0 / calc(0.05 * var(--clip-soft))),
    0 12px 24px rgb(0 0 0 / calc(0.04 * var(--clip-soft))),
    0 6px 12px rgb(0 0 0 / calc(0.03 * var(--clip-soft))),
    0 3px 6px rgb(0 0 0 / calc(0.02 * var(--clip-soft))),
    0 1.5px 3px rgb(0 0 0 / calc(0.02 * var(--clip-soft))),
    0 8px 28px var(--clip-deep);
  transform-origin: left top;
  animation: pop 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.95;
  }
}
.canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* The switch: a track with the four loaders, the picked one raised on a white (or night) pill. */
.switch {
  position: absolute;
  bottom: 10px;
  left: 50%;
  display: flex;
  translate: -50% 0;
  padding: 2px;
  border-radius: 999px;
  background: var(--field);
}
.option {
  appearance: none;
  margin: 0;
  padding: 2px 8px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-3);
  font: inherit;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1.5;
  letter-spacing: inherit;
  cursor: pointer;
  transition-property: background-color, color, box-shadow;
  transition-duration: 150ms;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.option:hover {
  color: var(--ink-2);
}
.option[aria-checked='true'] {
  background: var(--surface);
  color: var(--ink);
  box-shadow:
    0 0 0 1px var(--button-ring),
    0 0 4px var(--button-glow),
    0 1px 2px var(--button-drop);
}
.option:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

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

@media (prefers-reduced-motion: reduce) {
  /* The grid held still, part way through its laps (see heldOpacity). */
  .pixel {
    opacity: var(--held);
    animation: none;
  }
  .clip {
    animation: none;
  }
  .option {
    transition: none;
  }
}
```

### loading-state/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 };
}
```