# Loading wipe

> A loader that is swept off the page by a ribbon of colour, its edge churning like cloud or flame.

A loading surface that leaves by being swept away. A band of colour with a soft, turbulent edge crosses it in any of eight directions, then dissolves behind into the real page. A panel sets the colours, the direction and the noise, and a scrub plays the wipe by hand.

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

Category: Motion.

## Anatomy

1. **Preview**: The page and the loader over it. While the wipe runs, a WebGL canvas covers it and draws the loader, the ribbon and the page clearing behind.
2. **Loader words**: The loader’s line. The canvas draws it into a texture at the same place, so the hand-over from CSS to WebGL never shifts it.
3. **Sweep button**: A pane of glass: `Sweep` plays the wipe, `Sweeping…` waits, `Reset` puts the loader back with a short fade.
4. **Colour presets**: Five ribbons, a radio group: one tab stop, arrow keys move and pick. The colours are the same in light and dark.
5. **Ribbon colours**: The four colours of the picked preset, deepest first, spoken by name to a screen reader.
6. **Direction**: Eight arrows, another radio group: where the sweep travels, 0° towards the right, 90° down.
7. **Scrub**: The wipe’s progress by hand: it follows a sweep frame by frame, and dragging it stops the sweep where it is.

## When to use

- The one moment a loading surface hands over to the content: a splash, a first paint, an app shell opening.
- Brand moments where the hand-over itself is worth a second of attention, with a ribbon in the product’s colours.
- Tuning a reveal with a designer: every setting is a live uniform of the shader, and `Scrub` holds any frame.
- Hero sections or case studies that show a transition, where the reader may replay it with `Sweep`.

## When not to use

- Loading that repeats often, like a list paging or a tab switching. A skeleton or a quiet fade wears better.
- Waits of unknown length. The wipe is the exit, not the wait: show progress or a spinner until the content is ready.
- Pages that must stay light: it needs WebGL while it runs. Without it, or under reduced motion, it falls back to a cross-fade.
- Small surfaces like a card or a button: the turbulent edge needs room to read. Use a plain cross-fade there.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { LoadingWipe } from './loading-wipe';

// The wipe and its panel, without the demo: the reader sweeps it.
export function LoaderLab() {
  return <LoadingWipe autoplay={false} defaultPreset="ember" defaultAngle={0} />;
}
```

## States

- **Covered**: The loader whole, as a CSS stand-in with a fine grain: no WebGL until the wipe starts.
- **Part swept**: At rest midway, as a scrub leaves it: the ribbon crosses the preview and the button says `Reset`.
- **Another direction**: `ember` at 0°: the ribbon travels towards the right, its deepest colour trailing.
- **Torn edge**: A narrow ribbon with high turbulence: the noise tears the edge into lace.
- **Clear**: The wipe at its end: the loader is gone and the page shows. A sweep running is not locked here: it plays in the demo above.
- **Dark**: With `colorScheme="dark"`: the interface and the loader turn dark; the ribbon keeps its colours.

## How it works

- The page under the loader is plain HTML. At first the loader is a CSS stand-in: a plain surface with a fine noise grain and its words. Once the wipe starts, a WebGL canvas over the preview takes over. It draws the loader, its words (a texture of the real text, placed exactly where it sits), the ribbon, and nothing where the page is clear.
- The wipe is a field: each point’s distance behind a moving front, pushed about by value noise. Slow swells grow with the turbulence, and a finer churn grows with its cube until it tears the ribbon into lace. Swirl bends the noise’s domain with more noise, and bleed uses a second noise to hold points back near the trailing edge, so colour lingers in blotches that then dissolve.
- The field is computed on a coarse grid, one value every 6 CSS px, into a small texture, and read back at full resolution with bilinear filtering, which keeps it smooth and gives its contours a faintly blocky look at high turbulence. From it the shader picks the four ribbon colours (palest at the front, the deepest only tinting the dissolve), the feathered edges and a fine grain. The costly noise runs a few thousand times a frame, not a million.
- One requestAnimationFrame loop plays a sweep on an ease-in-out curve, and only while something moves. The Scrub slider follows the progress through a tiny external store, so only that row re-renders each frame. Every slider is a live shader uniform, and a wipe on show is redrawn as soon as one changes.
- GPU memory stays low four ways: the pixel ratio is capped at 2 and matched to the displayed size, so a shrunk gallery card gets fewer pixels; the canvas gives its pixels back after 4 s at rest, and at once off screen; the controls panel scrolls by hand (wheel, touch with coasting, keyboard focus) instead of being a native scroll area; and the card is a layout boundary of a fixed size, with the demo pointer built of boxes around static drawings, so nothing that moves inside it makes Safari lay out and repaint the whole page.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. The WebGL renderer reads the loader’s colour from the canvas’s computed style each draw, and a 1 ms colour transition tells it when the scheme changes; the ribbon’s colours stay as they are.

## API reference

- **defaultPreset** (`'sky' | 'ember' | 'dusk' | 'moss' | 'mono'`, default `'sky'`): The ribbon’s colours at first.
- **defaultAngle** (`number`, default `270`): Where the sweep travels at first, in degrees: 0 towards the right, 90 down, 270 up.
- **defaultDuration** (`number`, default `1200`): How long a sweep takes at first, in ms (300 to 4000).
- **defaultProgress** (`number`): Opens with the wipe this far along, from 0 (the loader whole) to 1 (the page clear), at rest. Turns the demo off.
- **defaultSettings** (`Partial<Settings>`): The panel’s other settings at first (spread, bleed, feather, swirl, turbulence, grain…). Turns the demo off.
- **onReveal** (`() => void`): Called each time a sweep has run to the end and the page is clear.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer plays with the wipe on its own.
- **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

- Sweep is a real button whose label follows the state: Sweep, Sweeping…, Reset. A hidden line describing what the loader is doing is linked to it, and it is aria-disabled while sweeping. Sweeps and resets the reader starts are announced in a polite live region; the demo’s own actions stay silent.
- The presets and the eight directions are radio groups with one tab stop each, and the arrow keys, Home and End move and pick. Each setting is a native range input with a visible label and an aria-valuetext that includes its unit. The ribbon swatches are one image named by their colours.
- The panel scrolls with the wheel and touch, and the browser scrolls it to whichever control takes the keyboard focus. Every control shows a visible focus ring.
- Under reduced motion there is no demo, and a sweep is a 0.4 s cross-fade instead of a moving edge. Without JavaScript or WebGL, the CSS loader stands in.

## Source

### loading-wipe/loading-wipe.tsx

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

'use client';

import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';
import {
  DirectionPicker,
  PresetPicker,
  RibbonRow,
  ScrubRow,
  Section,
  SliderRow,
  snap,
} from './controls';
import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';
import { SweepIcon } from './icons';
import {
  DEFAULTS,
  SECTIONS,
  SLIDERS,
  presetOf,
  type PresetId,
  type Settings,
  type SliderKey,
} from './presets';
import { createProgress, createSweep, type Look, type Phase, type Sweep } from './sweep';
import { useAutoplay } from './use-autoplay';
import { usePanelScroll } from './use-panel-scroll';
import styles from './loading-wipe.module.css';

/*
 * Loading wipe: a loading surface that leaves by being swept off. A ribbon of colour with a
 * turbulent edge crosses it and takes it away, and the page underneath shows through behind.
 * Beside it, a panel tunes the wipe.
 *
 * - The page is HTML. Over it, at first, the loader is a CSS stand-in: a grey surface with a fine
 *   grain and its words. Once the wipe runs, a WebGL canvas draws the loader, the ribbon and the
 *   clear page behind it (wipe.ts), and gives its pixels back when left at rest (sweep.ts).
 * - The sweep's progress lives outside React (sweep.ts): one animation frame loop plays it and
 *   draws, and only the Scrub slider follows it, frame by frame, through a small store.
 * - Every control is live: each setting is a uniform of the shader, and a wipe on show is drawn
 *   again as soon as one changes. Scrub sets the progress by hand. The panel of controls scrolls
 *   by hand too (use-panel-scroll.ts), to spare iOS a costly layer.
 * - While on screen and untouched, a demo pointer sweeps, resets, picks another colour, narrows
 *   the ribbon, raises the turbulence, scrubs, and turns the direction. Under reduced motion there
 *   is no demo, and a sweep is a short cross-fade.
 * - Light or dark: the interface's colours are light-dark() tokens in the stylesheet, so it
 *   follows the page's color-scheme, or the one the colorScheme prop sets. The canvas reads the
 *   loader's colours from the computed style, and draws again when the scheme changes; the
 *   ribbon's colours are the same in both.
 */

const lookOf = (settings: Settings): Look => ({
  angle: settings.angle,
  ribbon: presetOf(settings.preset).ribbon,
  spread: settings.spread,
  bleed: settings.bleed,
  feather: settings.feather,
  swirl: settings.swirl,
  turbulence: settings.turbulence,
  scale: settings.scale,
  grain: settings.grain,
});

const DESCRIPTIONS: Record<Phase, string> = {
  covered: 'A loading surface covers the page.',
  sweeping: 'The loading surface is being swept off the page.',
  paused: 'The loading surface is partly swept off the page.',
  clear: 'The loading surface is gone: the page shows.',
};

/** The size of a slider's thumb, in px (the stylesheet's --thumb): the demo grabs its middle. */
const THUMB = 11;

/** What each round of the demo turns to: a colour, then a direction to scrub along. */
const ROUNDS: { preset: PresetId; angle: number }[] = [
  { preset: 'ember', angle: 45 },
  { preset: 'dusk', angle: 180 },
  { preset: 'moss', angle: 135 },
  { preset: 'sky', angle: 0 },
];

/** What the demo does to the component, one step at a time. */
type DemoAct =
  | { type: 'start' }
  | { type: 'reset' }
  | { type: 'preset'; preset: PresetId }
  | { type: 'angle'; angle: number }
  | { type: 'value'; key: SliderKey; value: number };

export function LoadingWipe({
  defaultPreset = 'sky',
  defaultAngle = 270,
  defaultDuration = 1200,
  defaultProgress,
  defaultSettings,
  onReveal,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The ribbon's colours at first: sky, ember, dusk, moss or mono. */
  defaultPreset?: PresetId;
  /** Where the sweep travels at first, in degrees: 0 towards the right, 90 down, 270 up. */
  defaultAngle?: number;
  /** How long a sweep takes at first, in ms (300 to 4000). */
  defaultDuration?: number;
  /**
   * Opens with the wipe this far along, from 0 (the loader whole) to 1 (the page clear), at rest
   * as a scrub leaves it. Set, it turns the demo off.
   */
  defaultProgress?: number;
  /** The panel's other settings at first (spread, turbulence, grain…). Set, it turns the demo off. */
  defaultSettings?: Partial<Omit<Settings, 'preset' | 'angle' | 'duration'>>;
  /** Called each time a sweep has run to the end and the page is clear. */
  onReveal?: () => void;
  /** While on screen and untouched, a demo pointer plays with the wipe on its own. */
  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, take } = useAutoplay<HTMLDivElement>(10000);
  const [settings, setSettings] = useState<Settings>(() => ({
    ...DEFAULTS,
    ...defaultSettings,
    preset: defaultPreset,
    angle: defaultAngle,
    duration: defaultDuration,
  }));
  const [phase, setPhase] = useState<Phase>('covered');
  const [message, setMessage] = useState('');
  const [progress] = useState(createProgress);
  const descriptionId = useId();
  const surface = useRef<HTMLDivElement>(null);
  const loader = useRef<HTMLDivElement>(null);
  const words = useRef<HTMLSpanElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const bar = useRef<HTMLSpanElement>(null);
  const button = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const sweep = useRef<Sweep | null>(null);
  /** The sweep running now was started by the reader: its end is announced. */
  const byReader = useRef(false);
  const lastPhase = useRef<Phase>('covered');

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const onPhase = useEffectEvent((next: Phase) => {
    const was = lastPhase.current;
    lastPhase.current = next;
    setPhase(next);
    // A sweep that ran to the end (a scrub to the end is not one).
    if (next !== 'clear' || was !== 'sweeping') return;
    if (byReader.current) announce('The page is clear.');
    byReader.current = false;
    onReveal?.();
  });
  /** The settings as the sweep is made: later ones come in through setLook(). */
  const firstLook = useEffectEvent(() => lookOf(settings));
  /** Where the wipe opens: defaultProgress, read once as the sweep is made. */
  const firstProgress = useEffectEvent(() => defaultProgress ?? 0);

  // The sweep, its canvas, and what it needs to know: whether the wipe is near the screen, and
  // whether motion is reduced.
  useEffect(() => {
    const element = root.current;
    const host = surface.current;
    const standIn = loader.current;
    const text = words.current;
    if (!element || !host || !standIn || !text) return;
    // A fresh canvas for each sweep: a canvas whose WebGL context was released gets no other.
    const canvas = document.createElement('canvas');
    canvas.className = styles.canvas;
    canvas.width = 0;
    canvas.height = 0;
    host.append(canvas);
    const made = createSweep({
      canvas,
      loader: standIn,
      words: text,
      look: firstLook(),
      onProgress: progress.set,
      onPhase,
    });
    sweep.current = made;
    const opening = firstProgress();
    if (opening > 0) made.scrub(opening);

    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => made.setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);

    let onScreen = false;
    const update = () => made.setNear(onScreen && !document.hidden);
    // A little ahead of the screen, so the canvas is drawn before it shows.
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { rootMargin: '120px 0px' },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);

    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
      made.dispose();
      canvas.remove();
      sweep.current = null;
    };
  }, [root, progress]);

  useEffect(() => {
    sweep.current?.setLook(lookOf(settings));
  }, [settings]);

  usePanelScroll(panel, bar);

  /** Plays the wipe, by the reader's hand or the demo's. */
  const start = (reader: boolean) => {
    const made = sweep.current;
    if (!made || made.phase() !== 'covered') return;
    byReader.current = reader;
    if (reader) announce('Sweeping the loading surface off the page.');
    made.sweep(settings.duration);
  };

  /** Puts the loader back. */
  const reset = (reader: boolean) => {
    const made = sweep.current;
    if (!made || made.phase() === 'covered') return;
    byReader.current = false;
    made.reset();
    if (reader) announce('The loading surface is back.');
  };

  const setValue = (key: SliderKey, value: number) => {
    if (key === 'scrub') sweep.current?.scrub(value);
    else setSettings((now) => ({ ...now, [key]: value }));
  };

  const onButton = () => {
    if (phase === 'covered') start(true);
    else if (phase !== 'sweeping') reset(true);
  };

  // The demo: while on screen and untouched, the pointer sweeps, resets, picks another colour,
  // raises the turbulence, scrubs by hand, turns the direction and scrubs again.
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'start') start(false);
    else if (act.type === 'reset') reset(false);
    else if (act.type === 'preset') setSettings((now) => ({ ...now, preset: act.preset }));
    else if (act.type === 'angle') setSettings((now) => ({ ...now, angle: act.angle }));
    else setValue(act.key, act.value);
  });
  const demo =
    autoplay && defaultProgress === undefined && defaultSettings === undefined && playing;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const list = panel.current;
    if (!demo || !stage || !arrow || !list) return;
    const phaseNow = () => sweep.current?.phase() ?? 'covered';

    /** Where a slider's thumb is at `value`, on the screen. */
    const thumbAt = (input: HTMLInputElement, value: number): Point => {
      const box = input.getBoundingClientRect();
      const scale = box.width / input.offsetWidth || 1;
      const share = (value - Number(input.min)) / (Number(input.max) - Number(input.min));
      return {
        x: box.left + (THUMB / 2 + share * (input.offsetWidth - THUMB)) * scale,
        y: box.top + box.height / 2,
      };
    };

    const stop = runDemo(stage, arrow, async (steps: Demo) => {
      const { wait, moveTo, moveToPoint, press, drag, scroll } = steps;
      /** Scrolls the panel, if need be, to show `element` in its middle. */
      const reveal = async (element: Element) => {
        const frame = list.getBoundingClientRect();
        const scale = frame.width / list.offsetWidth || 1;
        const box = element.getBoundingClientRect();
        const top = (box.top - frame.top) / scale + list.scrollTop;
        const height = box.height / scale;
        if (top >= list.scrollTop && top + height <= list.scrollTop + list.clientHeight) return;
        const limit = list.scrollHeight - list.clientHeight;
        await scroll(list, Math.max(0, Math.min(limit, top - (list.clientHeight - height) / 2)));
      };
      const click = async (element: Element | null, act: DemoAct) => {
        if (!element) return;
        await reveal(element);
        await moveTo(element);
        await press(() => demoAct(act));
      };
      /** Grabs a slider's thumb and drags it to `to` over `ms`. */
      const slide = async (key: SliderKey, to: number, ms: number) => {
        const input = stage.querySelector<HTMLInputElement>(`input[data-key="${key}"]`);
        if (!input) return;
        await reveal(input);
        const from = Number(input.value);
        await moveToPoint(thumbAt(input, from));
        await wait(120);
        const { step } = SLIDERS[key];
        await drag(thumbAt(input, to), ms, (share) =>
          demoAct({ type: 'value', key, value: snap(from + (to - from) * share, step) }),
        );
        await wait(160);
      };
      const until = async (test: () => boolean) => {
        while (!test()) await wait(120);
      };

      for (let round = 0; ; round += 1) {
        const next = ROUNDS[round % ROUNDS.length];
        await wait(round ? 900 : 700);
        if (phaseNow() !== 'covered') {
          await click(button.current, { type: 'reset' });
          await wait(600);
        }
        // A slower sweep, to watch it go.
        if (round === 0) await slide('duration', 3000, 700);
        await click(button.current, { type: 'start' });
        await until(() => phaseNow() === 'clear');
        await wait(900);
        await click(button.current, { type: 'reset' });
        await wait(500);

        // Another colour, a narrower ribbon and much more turbulence; then the scrub, by hand.
        await click(stage.querySelector(`[data-preset="${next.preset}"]`), {
          type: 'preset',
          preset: next.preset,
        });
        await wait(300);
        await slide('spread', 0.45, 600);
        await slide('turbulence', 0.75, 900);
        await slide('scrub', 0.42, 1500);
        await wait(700);
        await slide('scrub', 0.64, 1000);
        await wait(600);
        await slide('scrub', 1, 900);
        await wait(800);

        // Another direction, scrubbed all the way.
        await click(button.current, { type: 'reset' });
        await wait(400);
        await click(stage.querySelector(`[data-angle="${next.angle}"]`), {
          type: 'angle',
          angle: next.angle,
        });
        await wait(300);
        await slide('scrub', 1, 2600);
        await wait(900);

        // Calm again, and upwards, for the next round's sweep.
        await click(button.current, { type: 'reset' });
        await slide('turbulence', DEFAULTS.turbulence, 600);
        await slide('spread', DEFAULTS.spread, 500);
        await click(stage.querySelector(`[data-angle="${DEFAULTS.angle}"]`), {
          type: 'angle',
          angle: DEFAULTS.angle,
        });
      }
    });
    return stop;
  }, [demo, root]);

  const preset = presetOf(settings.preset);
  const label = phase === 'covered' ? 'Sweep' : phase === 'sweeping' ? 'Sweeping…' : 'Reset';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      role="group"
      aria-label="Loading wipe"
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.frame}>
        <div className={styles.card}>
          <div className={styles.preview} role="group" aria-label="Preview" data-part="preview">
            <div className={styles.page} aria-hidden="true" data-part="page">
              <div className={styles.skeleton}>
                <div className={styles.head}>
                  <span className={styles.avatar} />
                  <span className={styles.lines}>
                    <span />
                    <span />
                  </span>
                  <span className={styles.pill} />
                </div>
                <div className={styles.text}>
                  <span />
                  <span />
                  <span />
                </div>
                <div className={styles.blocks}>
                  <span />
                  <span />
                  <span />
                </div>
              </div>
            </div>
            <div ref={surface} className={styles.surface} aria-hidden="true" data-part="canvas" />
            <div
              ref={loader}
              className={styles.loader}
              data-part="loader"
              style={{ '--grain': settings.grain } as CSSProperties}
              aria-hidden="true"
            >
              <span ref={words} className={styles.words} data-part="words">
                Loader content
              </span>
            </div>
            <p id={descriptionId} className={styles.hidden}>
              {DESCRIPTIONS[phase]}
            </p>
            <button
              ref={button}
              type="button"
              className={styles.sweep}
              data-part="sweep"
              data-phase={phase}
              aria-disabled={phase === 'sweeping' || undefined}
              aria-describedby={descriptionId}
              onClick={onButton}
            >
              <SweepIcon className={styles.sweepIcon} />
              {label}
            </button>
          </div>

          <div className={styles.side}>
            <div ref={panel} className={styles.panel} data-part="panel">
              <Section title="Colour">
                <PresetPicker
                  value={settings.preset}
                  onChange={(id) => setSettings((now) => ({ ...now, preset: id }))}
                />
                <RibbonRow preset={preset} />
              </Section>
              <Section title="Sweep">
                <DirectionPicker
                  angle={settings.angle}
                  onChange={(angle) => setSettings((now) => ({ ...now, angle }))}
                />
                {SECTIONS.sweep.map((key) =>
                  key === 'scrub' ? (
                    <ScrubRow
                      key={key}
                      progress={progress}
                      onChange={(value) => setValue(key, value)}
                    />
                  ) : (
                    <SliderRow
                      key={key}
                      spec={SLIDERS[key]}
                      value={settings[key]}
                      onChange={(value) => setValue(key, value)}
                    />
                  ),
                )}
              </Section>
              <Section title="Ribbon">
                {SECTIONS.ribbon.map((key) => (
                  <SliderRow
                    key={key}
                    spec={SLIDERS[key]}
                    value={settings[key]}
                    onChange={(value) => setValue(key, value)}
                  />
                ))}
              </Section>
              <Section title="Edge noise">
                {SECTIONS.noise.map((key) => (
                  <SliderRow
                    key={key}
                    spec={SLIDERS[key]}
                    value={settings[key]}
                    onChange={(value) => setValue(key, value)}
                  />
                ))}
              </Section>
              <Section title="Grain">
                {SECTIONS.grain.map((key) => (
                  <SliderRow
                    key={key}
                    spec={SLIDERS[key]}
                    value={settings[key]}
                    onChange={(value) => setValue(key, value)}
                  />
                ))}
              </Section>
            </div>
            <span ref={bar} className={styles.bar} aria-hidden="true" />
          </div>
          <p className={styles.hidden} role="status">
            {message}
          </p>
        </div>
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
    </div>
  );
}
```

### loading-wipe/loading-wipe.module.css

```css
/* Loading wipe (loading-wipe.tsx): a stage with one card in its middle, the preview on the left
   (the page, the loader over it, the canvas while the wipe runs) and the panel of controls on the
   right, which scrolls. In a narrow container the panel goes under the preview.

   Colours. The interface's 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 ribbon's colours (presets.ts) are the
   effect: the same in both. The canvas paints the loader in the colour .canvas carries, and its
   words in theirs, both read from the computed style (sweep.ts), so they follow the scheme too.

   Memory. A phone keeps a page's pixels in tiles the width of the screen, about 7 MB each, and a
   tile that is painted again gets a second buffer. So what changes while the wipe plays stays
   off the page's tiles: the panel, the loader's stand-in, the button and the lines for screen
   readers each paint on a small layer of their own (will-change: opacity), and the card is a
   layout boundary, so a change inside it lays out the card alone. There must be only one: a
   browser lays out one boundary at a time, and two in the same frame (say the panel and the
   preview) make it lay out the whole page instead, which paints its tiles again. The pointer
   over the card needs no layout while the demo plays (demo-pointer.tsx). */

.root {
  /* The stage; the card on it, which is also the page under the loader, and the unpicked
     buttons; the band of a section's title. */
  --surface: #f2f1f4;
  --surface-2: #fff;
  --button: #fff;
  --head: #fafafb;
  /* The words: names and hovers; labels; the presets' names; the quiet ones (section titles,
     values, the loader's words, the arrows). */
  --ink: #18181b;
  --text: #5c5c66;
  --ink-muted: #71717a;
  --faint: #a1a1aa;
  /* A picked preset or direction: dark with white words by day, the other way round at night. */
  --accent: #11141b;
  --accent-ink: #fff;
  /* Hairlines: the stage's edge, the card's; between the panel's parts, between its rows. */
  --edge: rgb(0 0 0 / 0.06);
  --card-edge: rgb(0 0 0 / 0.08);
  --line: #ebebee;
  --hair: #f3f3f5;
  /* The card's shadow. */
  --shadow: rgb(0 0 0 / 0.05);
  /* The page under the loader: its skeleton's shapes. */
  --bone: #e4e4e8;
  --bone-soft: #f0f0f2;
  --bone-pill: #f2f2f4;
  --bone-block: #f2f2f3;
  /* The loader's stand-in, under its grain; and the same surface as the canvas paints it, its
     grain averaged in (.canvas carries it to the wipe). */
  --loader: #fff;
  --loader-mean: #f8f8f8;
  /* Sweep: a pane of glass over the preview, and its words. */
  --glass: rgb(255 255 255 / 0.6);
  --glass-hover: rgb(255 255 255 / 0.85);
  --glass-edge: rgb(0 0 0 / 0.06);
  --glass-shadow: rgb(0 0 0 / 0.05);
  --glass-ink: #52525b;
  /* A slider: its fill and track, its thumb (white on both) and the thumb's edge and shadow. */
  --fill: #1d1d22;
  --track: #e4e4e7;
  --knob: #fff;
  --knob-edge: rgb(0 0 0 / 0.14);
  --knob-shadow: rgb(0 0 0 / 0.22);
  /* The panel's scroll bar, while it moves. */
  --scroll: rgb(0 0 0 / 0.38);
  /* The focus rings, and the gap a focused thumb keeps round it. */
  --focus: #3b82f6;
  --focus-gap: #fff;
  /* The demo's pointer (demo-pointer.tsx): the system's, the same on both. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --thumb: 11px;
  --mono: 'JetBrains Mono', JetBrains, Menlo, Consolas, monospace;
  position: relative;
  padding: clamp(16px, 6%, 52px) clamp(10px, 4%, 48px);
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font:
    400 12px/1.4 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f2f1f4, #0f0f11);
    --surface-2: light-dark(#fff, #18181b);
    --button: light-dark(#fff, #202024);
    --head: light-dark(#fafafb, #1d1d21);
    --ink: light-dark(#18181b, #f4f4f5);
    --text: light-dark(#5c5c66, #b4b4bd);
    --ink-muted: light-dark(#71717a, #a1a1aa);
    --faint: light-dark(#a1a1aa, #8e8e98);
    --accent: light-dark(#11141b, #f4f4f5);
    --accent-ink: light-dark(#fff, #18181b);
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --card-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08));
    --line: light-dark(#ebebee, #2a2a2f);
    --hair: light-dark(#f3f3f5, #222226);
    --shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --bone: light-dark(#e4e4e8, #2e2e33);
    --bone-soft: light-dark(#f0f0f2, #252529);
    --bone-pill: light-dark(#f2f2f4, #26262a);
    --bone-block: light-dark(#f2f2f3, #232327);
    --loader: light-dark(#fff, #1c1c1f);
    --loader-mean: light-dark(#f8f8f8, #242427);
    --glass: light-dark(rgb(255 255 255 / 0.6), rgb(24 24 27 / 0.72));
    --glass-hover: light-dark(rgb(255 255 255 / 0.85), rgb(24 24 27 / 0.9));
    --glass-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.1));
    --glass-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --glass-ink: light-dark(#52525b, #d4d4d8);
    --fill: light-dark(#1d1d22, #e4e4e7);
    --track: light-dark(#e4e4e7, #35353b);
    --knob-edge: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.4));
    --knob-shadow: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.5));
    --scroll: light-dark(rgb(0 0 0 / 0.38), rgb(255 255 255 / 0.3));
    --focus: light-dark(#3b82f6, #5b9bff);
    --focus-gap: light-dark(#fff, #18181b);
  }
}
/* 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;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
/* Buttons and inputs take the stage's type; the classes below set their own on top. */
.root :where(button, input) {
  margin: 0;
  font: inherit;
  color: inherit;
}

.frame {
  container: card / inline-size;
  width: min(100%, 630px);
  margin: 0 auto;
}
.card {
  display: grid;
  grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
  grid-template-rows: minmax(0, 1fr);
  height: 400px;
  overflow: hidden;
  overflow: clip;
  /* Its size comes from the frame and its own height, never from what is inside it. */
  contain: size layout;
  border-radius: 10px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 1px 3px var(--shadow);
}

/* The preview: the page, the canvas over it while the wipe runs, the loader's stand-in, and the
   button. */
.preview {
  position: relative;
  overflow: hidden;
  overflow: clip;
}

.page {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: 0 clamp(18px, 8%, 30px);
}
.skeleton {
  width: 100%;
}
.head {
  display: flex;
  align-items: center;
  gap: 9px;
  height: 36px;
}
.avatar {
  flex: none;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--bone);
}
.lines {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 6px;
  margin-top: -1.5px;
}
.lines span {
  width: 60%;
  height: 9px;
  border-radius: 999px;
  background: var(--bone);
}
.lines span + span {
  width: 37%;
  height: 8px;
  background: var(--bone-soft);
}
.pill {
  flex: none;
  width: 57px;
  height: 21px;
  margin-top: -1px;
  border-radius: 999px;
  background: var(--bone-pill);
}
.text {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 18px;
}
.text span {
  height: 7px;
  border-radius: 999px;
  background: var(--bone-soft);
}
.text span:nth-child(2) {
  width: 92%;
}
.text span:nth-child(3) {
  width: 65%;
}
.blocks {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
  margin-top: 19px;
}
.blocks span {
  aspect-ratio: 100 / 77;
  border-radius: 8px;
  background: var(--bone-block);
}

/* The wipe's canvas, while it runs (its pixels are given back at rest), in a box over the page. */
.surface {
  position: absolute;
  z-index: 2;
  inset: 0;
  pointer-events: none;
}
.canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  visibility: hidden;
  /* The loader as the canvas paints it (sweep.ts reads it). A change of colour scheme runs this
     unseen transition, which tells the wipe to draw again in the new colours. */
  color: var(--loader-mean);
  transition: color 1ms;
}
.canvas[data-drawn] {
  visibility: visible;
}

/* The loader at rest: a pale surface under a fine grain (a tile of fractal noise, scaled by the
   grain's size), with its words a little above the middle. */
.loader {
  --grain: 1.3;
  position: absolute;
  z-index: 1;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-bottom: 14px;
  will-change: opacity;
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0.065'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E")
      0 0 / calc(var(--grain) * 160px) repeat,
    var(--loader);
}
/* Hidden by its opacity, which changes its layer and nothing under it. */
.loader[data-gone] {
  opacity: 0;
}
.words {
  display: block;
  font-size: 14px;
  line-height: 20px;
  color: var(--faint);
  white-space: nowrap;
}

.sweep {
  position: absolute;
  z-index: 3;
  will-change: opacity;
  right: 10px;
  bottom: 10px;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 30px;
  padding: 0 14px 0 12px;
  border: 0;
  border-radius: 999px;
  background: var(--glass);
  box-shadow:
    0 0 0 1px var(--glass-edge),
    0 1px 2px var(--glass-shadow);
  font-weight: 500;
  line-height: 1;
  color: var(--glass-ink);
  cursor: pointer;
  transition: background-color 0.15s ease;
}
.sweep:hover {
  background: var(--glass-hover);
}
.sweep[aria-disabled] {
  cursor: default;
}
.sweepIcon {
  flex: none;
  width: 14px;
  height: 14px;
}
/* One icon for each phase, both in the same SVG (icons.tsx): the card is in a query container,
   and there, an SVG added to the page makes Safari lay out the whole page. */
.sweep[data-phase='covered'] [data-icon='loop'],
.sweep:not([data-phase='covered']) [data-icon='play'] {
  visibility: hidden;
}
.sweep[data-phase='sweeping'] .sweepIcon {
  animation: turn 1s linear infinite;
}
@keyframes turn {
  to {
    transform: rotate(1turn);
  }
}

/* The panel: titled sections of rows, 36 px each, divided by hairlines. It scrolls by hand
   (use-panel-scroll.ts), with a thin bar that shows while it moves. */
.side {
  position: relative;
  min-width: 0;
  min-height: 0;
  border-left: 1px solid var(--line);
  will-change: opacity;
}
.panel {
  height: 100%;
  overflow: hidden;
}
.bar {
  position: absolute;
  top: 0;
  right: 2px;
  width: 4px;
  border-radius: 2px;
  background: var(--scroll);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
.bar[data-shown] {
  opacity: 1;
  transition-duration: 0.1s;
}
.sectionTitle {
  display: flex;
  align-items: center;
  height: 34px;
  margin: 0;
  padding: 0 12px;
  border-top: 1px solid var(--line);
  background: var(--head);
  font: 500 10px/1 var(--mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
}
.section:first-child .sectionTitle {
  height: 36px;
  border-top: 0;
}
.row,
.presets,
.directions {
  border-top: 1px solid var(--hair);
}
.sectionTitle + .row,
.sectionTitle + .presets,
.sectionTitle + .directions {
  border-top: 0;
}

.row {
  display: grid;
  grid-template-columns: clamp(60px, 38.3%, 88px) minmax(0, 1fr) 58px;
  align-items: center;
  height: 36px;
  padding: 0 9px 0 12px;
}
.label {
  overflow: hidden;
  font-size: 12px;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--text);
}
.value {
  justify-self: end;
  font: 400 12px/1 var(--mono);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--faint);
}

.presets {
  display: flex;
  flex-wrap: wrap;
  gap: 7px 5px;
  padding: 6px 9px 6px 12px;
}
.preset {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 25px;
  padding: 0 10px 0 4px;
  border: 0;
  border-radius: 999px;
  background: var(--button);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: 11px;
  line-height: 1;
  color: var(--ink-muted);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.preset:hover:not([aria-checked='true']) {
  color: var(--ink);
}
.root .preset[aria-checked='true'] {
  background: var(--accent);
  box-shadow: none;
  font-weight: 500;
  color: var(--accent-ink);
}
.presetSwatch {
  flex: none;
  width: 22px;
  height: 16px;
  border-radius: 999px;
  background: var(--swatch);
}

/* The ribbon's row is a little taller than the others, as is the directions'. */
.ribbonRow {
  height: 40px;
}
.ribbon {
  display: grid;
  grid-column: 2 / -1;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 7px;
  height: 24px;
}
.ribbon span {
  border-radius: 6px;
  transition: background-color 0.2s ease;
}

.directions {
  display: grid;
  grid-template-columns: repeat(8, minmax(0, 1fr));
  gap: 4px;
  align-items: center;
  height: 38px;
  padding: 0 9px 0 12px;
}
.direction {
  display: grid;
  place-items: center;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 7px;
  background: var(--button);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--faint);
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.direction:hover:not([aria-checked='true']) {
  color: var(--ink);
}
.root .direction[aria-checked='true'] {
  background: var(--accent);
  box-shadow: none;
  color: var(--accent-ink);
}
.arrow {
  width: 12px;
  height: 12px;
}

/* A slider: a thin dark fill up to a round white thumb, then the light track. The fill is a
   gradient whose stop follows --share (the value, 0 to 1) to the thumb's middle. */
.slider {
  --stop: calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--share));
  width: 100%;
  height: 18px;
  padding: 0;
  background: transparent;
  cursor: pointer;
  appearance: none;
  touch-action: pan-y;
}
.slider::-webkit-slider-runnable-track {
  height: 6px;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--fill) var(--stop), var(--track) var(--stop));
}
.slider::-moz-range-track {
  height: 6px;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--fill) var(--stop), var(--track) var(--stop));
}
.slider::-webkit-slider-thumb {
  width: var(--thumb);
  height: var(--thumb);
  margin-top: calc((6px - var(--thumb)) / 2);
  border: 0;
  border-radius: 50%;
  background: var(--knob);
  box-shadow:
    0 0 0 0.5px var(--knob-edge),
    0 1px 2.5px var(--knob-shadow);
  appearance: none;
}
.slider::-moz-range-thumb {
  width: var(--thumb);
  height: var(--thumb);
  border: 0;
  border-radius: 50%;
  background: var(--knob);
  box-shadow:
    0 0 0 0.5px var(--knob-edge),
    0 1px 2.5px var(--knob-shadow);
}

.sweep:focus-visible,
.preset:focus-visible,
.direction:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.slider:focus-visible {
  outline: none;
}
.slider:focus-visible::-webkit-slider-thumb {
  box-shadow:
    0 0 0 2px var(--focus-gap),
    0 0 0 4px var(--focus);
}
.slider:focus-visible::-moz-range-thumb {
  box-shadow:
    0 0 0 2px var(--focus-gap),
    0 0 0 4px var(--focus);
}

/* The demo's pointer: an arrow, or a closed hand while it drags. It moves over the whole stage,
   in a box of its own, contained, so that nothing in it can ever lay out the page around it. */
.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;
  will-change: opacity;
}

/* Narrow: the panel under the preview, both of a fixed height (the panel scrolls). */
@container card (max-width: 499px) {
  .card {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: 290px 300px;
    height: 590px;
  }
  .side {
    border-top: 1px solid var(--line);
    border-left: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    transition: none !important;
    animation: none !important;
  }
}
```

### loading-wipe/controls.tsx

```tsx
'use client';

import {
  useId,
  useSyncExternalStore,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from 'react';
import { ArrowIcon } from './icons';
import {
  DIRECTIONS,
  PRESETS,
  SLIDERS,
  type Preset,
  type PresetId,
  type SliderSpec,
} from './presets';
import type { Progress } from './sweep';
import styles from './loading-wipe.module.css';

/*
 * The panel's controls. The presets and the directions are radio groups (one tab stop each; the
 * arrow keys move and pick), the settings are native range inputs, whose fill is a CSS gradient
 * set from the value. The Scrub slider follows the wipe's progress on its own, frame by frame.
 */

/** A titled part of the panel. */
export function Section({ title, children }: { title: string; children: ReactNode }) {
  const id = useId();
  return (
    <div className={styles.section} role="group" aria-labelledby={id}>
      <p id={id} className={styles.sectionTitle}>
        {title}
      </p>
      {children}
    </div>
  );
}

/** The arrow keys, Home and End move through a radio group, picking as they go. */
function moveIn(event: KeyboardEvent<HTMLDivElement>, pick: (index: number) => void) {
  const radios = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[role="radio"]'));
  const at = radios.indexOf(event.target as HTMLElement);
  const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
  let next = -1;
  if (event.key in step) next = (Math.max(0, at) + step[event.key] + radios.length) % radios.length;
  else if (event.key === 'Home') next = 0;
  else if (event.key === 'End') next = radios.length - 1;
  if (next < 0) return;
  event.preventDefault();
  pick(next);
  radios[next].focus();
}

const gradientOf = (preset: Preset) => `linear-gradient(90deg, ${preset.ribbon.join(', ')})`;

export function PresetPicker({
  value,
  onChange,
}: {
  value: PresetId;
  onChange: (id: PresetId) => void;
}) {
  return (
    <div
      className={styles.presets}
      role="radiogroup"
      aria-label="Colour preset"
      onKeyDown={(event) => moveIn(event, (index) => onChange(PRESETS[index].id))}
    >
      {PRESETS.map((preset) => (
        <button
          key={preset.id}
          type="button"
          role="radio"
          aria-checked={preset.id === value}
          tabIndex={preset.id === value ? 0 : -1}
          className={styles.preset}
          data-preset={preset.id}
          onClick={() => onChange(preset.id)}
        >
          <span
            className={styles.presetSwatch}
            style={{ '--swatch': gradientOf(preset) } as CSSProperties}
            aria-hidden="true"
          />
          {preset.name}
        </button>
      ))}
    </div>
  );
}

/** The ribbon's four colours, deepest first. */
export function RibbonRow({ preset }: { preset: Preset }) {
  const [deep, middle, light, pale] = preset.names;
  return (
    <div className={`${styles.row} ${styles.ribbonRow}`}>
      <span className={styles.label} aria-hidden="true">
        Ribbon
      </span>
      <span
        className={styles.ribbon}
        role="img"
        aria-label={`Ribbon colours: ${deep}, ${middle}, ${light} and ${pale}`}
      >
        {preset.ribbon.map((colour) => (
          <span key={colour} style={{ background: colour }} />
        ))}
      </span>
    </div>
  );
}

export function DirectionPicker({
  angle,
  onChange,
}: {
  angle: number;
  onChange: (angle: number) => void;
}) {
  const checked = DIRECTIONS.findIndex((direction) => direction.angle === angle);
  // With no direction picked (a free angle), the first one takes the tab stop.
  const stop = Math.max(0, checked);
  return (
    <div
      className={styles.directions}
      role="radiogroup"
      aria-label="Sweep direction"
      onKeyDown={(event) => moveIn(event, (index) => onChange(DIRECTIONS[index].angle))}
    >
      {DIRECTIONS.map((direction, index) => (
        <button
          key={direction.angle}
          type="button"
          role="radio"
          aria-checked={index === checked}
          aria-label={direction.label}
          tabIndex={index === stop ? 0 : -1}
          className={styles.direction}
          data-angle={direction.angle}
          onClick={() => onChange(direction.angle)}
        >
          <ArrowIcon angle={direction.angle} className={styles.arrow} />
        </button>
      ))}
    </div>
  );
}

export function SliderRow({
  spec,
  value,
  onChange,
}: {
  spec: SliderSpec;
  value: number;
  onChange: (value: number) => void;
}) {
  const id = useId();
  const share = (value - spec.min) / (spec.max - spec.min);
  return (
    <div className={styles.row}>
      <label className={styles.label} htmlFor={id}>
        {spec.label}
      </label>
      <input
        id={id}
        className={styles.slider}
        type="range"
        min={spec.min}
        max={spec.max}
        step={spec.step}
        value={value}
        aria-valuetext={spec.spoken(value)}
        data-key={spec.key}
        style={{ '--share': share } as CSSProperties}
        onChange={(event) => onChange(Number(event.target.value))}
      />
      <output className={styles.value} htmlFor={id} aria-hidden="true">
        {spec.format(value)}
      </output>
    </div>
  );
}

/** `value` on the grid of `step`, without floating-point dust. */
export const snap = (value: number, step: number) => {
  const decimals = (String(step).split('.')[1] ?? '').length;
  return Number((Math.round(value / step) * step).toFixed(decimals));
};

/** The Scrub slider: the wipe's progress, which it follows while a sweep plays, and sets. */
export function ScrubRow({
  progress,
  onChange,
}: {
  progress: Progress;
  onChange: (value: number) => void;
}) {
  const value = useSyncExternalStore(progress.subscribe, progress.get, () => 0);
  return (
    <SliderRow spec={SLIDERS.scrub} value={snap(value, SLIDERS.scrub.step)} onChange={onChange} />
  );
}
```

### loading-wipe/sweep.ts

```tsx
import { createWipe, pixelRatio, type Box, type Wipe, type WipeFrame } from './wipe';

/*
 * The sweep: the wipe's progress, from 0 (the loader whole) to 1 (the page clear), the one
 * animation frame loop that plays it, and what shows it in the preview.
 *
 * - At first the loader is a CSS stand-in. Once the progress leaves 0, the canvas takes over:
 *   sized to the preview, it draws the loader, its words and the ribbon, and the stand-in hides
 *   under it. It keeps showing the last frame at rest (the page clear, or the loader back after a
 *   reset, which fades in), and gives its pixels back after a few seconds unused, when the
 *   stand-in returns. A sweep soon after reuses the pixels rather than asking for new ones: on
 *   iOS, pixels given back linger in a pool for a while, so trading them often costs memory.
 * - A sweep plays the progress to 1 on an ease-in-out curve; a scrub sets it by hand. The loop
 *   runs only while a sweep or a fade plays: a scrub, a new setting or a resize draws one frame.
 * - Off screen, the canvas gives its pixels back at once, and a sweep in progress keeps its
 *   clock, so the wipe is where it should be when it comes back.
 * - Under reduced motion, or without WebGL, the canvas is never used: the stand-in fades out as
 *   the progress grows, so a sweep is a short cross-fade.
 * - The canvas paints in the stylesheet's colours, read from the computed style each time it
 *   draws: the loader's (the colour .canvas carries) and its words'. A change of colour scheme
 *   runs a short transition on the canvas's colour, and its end draws the wipe again.
 */

/** Covered: the loader is whole. Paused: part swept, at rest. Clear: the page shows. */
export type Phase = 'covered' | 'sweeping' | 'paused' | 'clear';

/** Everything the shader needs but the moment (progress, seed, opacity) and the loader's colour. */
export type Look = Omit<WipeFrame, 'progress' | 'seed' | 'opacity' | 'loader'>;

/** A sweep under reduced motion: a cross-fade this long, in ms. */
const FADE = 400;
/** The loader's return after a reset: a fade this long, in ms. */
const RETURN = 320;
/** How long the canvas keeps its pixels at rest, in ms. */
const KEEP = 4000;
/** Around the loader's words in their texture, in CSS px, so its edges are clear. */
const PAD = 2;

const clamp = (value: number) => Math.min(1, Math.max(0, value));
/** A computed colour, 'rgb(r, g, b)', as 0–1 RGB. */
const channels = (colour: string) =>
  [0, 1, 2].map((at) => Number(colour.match(/[\d.]+/g)?.[at] ?? 0) / 255);
const ease = (t: number) => (1 - Math.cos(Math.PI * t)) / 2;
const easeOut = (t: number) => 1 - (1 - t) ** 3;

/**
 * The progress as a small store: a value that tells its listeners when it changes, so the one
 * control that shows it (the Scrub slider) can follow every frame without the rest re-rendering.
 */
export function createProgress() {
  let value = 0;
  const listeners = new Set<() => void>();
  return {
    get: () => value,
    set(next: number) {
      if (next === value) return;
      value = next;
      listeners.forEach((listener) => listener());
    },
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
  };
}

export type Progress = ReturnType<typeof createProgress>;

type Options = {
  canvas: HTMLCanvasElement;
  /** The CSS stand-in for the loader, and its words. */
  loader: HTMLElement;
  words: HTMLElement;
  look: Look;
  /** Called with each new progress: every frame of a sweep. */
  onProgress: (progress: number) => void;
  onPhase: (phase: Phase) => void;
};

export function createSweep(options: Options) {
  const { canvas, loader, words, onProgress, onPhase } = options;
  /** Made on the first wipe; null where WebGL is missing. */
  let wipe: Wipe | null | undefined;
  let look = options.look;
  /** Moves the noise along after each reset, so no two wipes look alike. */
  let seed = 0;
  let progress = 0;
  let phase: Phase = 'covered';
  /** A sweep playing, and the loader's return after a reset. */
  let run: { from: number; start: number; ms: number } | null = null;
  let back: { start: number } | null = null;
  let frame = 0;
  let near = false;
  let reduced = false;
  /** The canvas's box in CSS px, the size its pixels have now ('' for none), and its words. */
  let box = { width: 0, height: 0 };
  let ratio = 1;
  let pixels = '';
  let worded = '';
  let idle = 0;
  /** The loader's words, drawn again when the preview's size changes. */
  const image = document.createElement('canvas');

  const setPhase = () => {
    const next: Phase = run
      ? 'sweeping'
      : progress <= 0
        ? 'covered'
        : progress >= 1
          ? 'clear'
          : 'paused';
    if (next === phase) return;
    phase = next;
    onPhase(next);
  };

  /** The stand-in shows unless the page is clear. */
  const standIn = () => loader.toggleAttribute('data-gone', progress >= 1);

  /** The canvas hides and gives its pixels back; the stand-in shows in its place. */
  const release = () => {
    window.clearTimeout(idle);
    idle = 0;
    canvas.removeAttribute('data-drawn');
    // The words are drawn again next time: the page's fonts may have changed meanwhile.
    worded = '';
    if (pixels) {
      wipe?.resize(0, 0, 1);
      pixels = '';
    }
    standIn();
  };

  /** The loader's words, drawn into a texture where they are in the preview. */
  const drawWords = (renderer: Wipe) => {
    const frameBox = canvas.getBoundingClientRect();
    // Screen pixels to the canvas's own, should it sit in a scaled container.
    const scale = frameBox.width / box.width || 1;
    const textBox = words.getBoundingClientRect();
    const left = (textBox.left - frameBox.left) / scale;
    const top = (textBox.top - frameBox.top) / scale;
    const lineHeight = textBox.height / scale;
    // On whole device pixels, so the texture's texels land on the canvas's pixels.
    const place: Box = {
      x: Math.floor((left - PAD) * ratio) / ratio,
      y: Math.floor((top - PAD) * ratio) / ratio,
      width: Math.ceil((textBox.width / scale + 2 * PAD) * ratio) / ratio,
      height: Math.ceil((lineHeight + 2 * PAD) * ratio) / ratio,
    };
    // Sizing the canvas also clears it.
    image.width = Math.round(place.width * ratio);
    image.height = Math.round(place.height * ratio);
    const context = image.getContext('2d');
    if (!context) return renderer.words(null, place);
    const style = getComputedStyle(words);
    const text = words.textContent ?? '';
    context.scale(ratio, ratio);
    context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
    context.fillStyle = style.color;
    // The baseline where the page puts it: the line's leading split above and below the font.
    const { fontBoundingBoxAscent: ascent, fontBoundingBoxDescent: descent } =
      context.measureText(text);
    let baseline = top - place.y + lineHeight / 2;
    if (Number.isFinite(ascent) && Number.isFinite(descent)) {
      baseline = top - place.y + (lineHeight - ascent - descent) / 2 + ascent;
    } else {
      context.textBaseline = 'middle';
    }
    context.fillText(text, left - place.x, baseline);
    renderer.words(image, place);
  };

  /** Shows the wipe as it is now; `opacity` fades the canvas in as the loader returns. */
  const render = (opacity = 1) => {
    if (reduced || wipe === null) {
      // A cross-fade: the stand-in fades out as the progress grows.
      release();
      loader.style.opacity = String(1 - progress);
      return;
    }
    loader.style.opacity = '';
    if (!near || !box.width || !box.height) return release();
    const moving = run !== null || back !== null;
    const middle = progress > 0 && progress < 1;
    // At rest with no pixels: the stand-in alone.
    if (!middle && !moving && !pixels) return standIn();
    if (wipe === undefined) wipe = createWipe(canvas);
    if (!wipe) return render();
    const size = `${box.width}×${box.height}`;
    if (size !== pixels) {
      // As many pixels as the canvas shows: fewer when its container is drawn shrunk.
      ratio = pixelRatio(canvas.getBoundingClientRect().width / box.width || 1);
      wipe.resize(box.width, box.height, ratio);
      pixels = size;
    }
    if (worded !== size) {
      drawWords(wipe);
      worded = size;
    }
    const loaderColour = channels(getComputedStyle(canvas).color);
    const drawn = wipe.draw({ ...look, progress, seed, opacity, loader: loaderColour });
    canvas.toggleAttribute('data-drawn', drawn);
    // While the canvas shows the loader, the stand-in hides under it.
    loader.toggleAttribute('data-gone', drawn || progress >= 1);
    // Unused at rest for a while, the pixels go back.
    window.clearTimeout(idle);
    idle = middle || moving ? 0 : window.setTimeout(release, KEEP);
  };

  const tick = (now: number) => {
    frame = 0;
    // A frame's timestamp can be a little older than the start: times are clamped at both ends.
    if (run) {
      const t = clamp((now - run.start) / run.ms);
      progress = clamp(run.from + (1 - run.from) * ease(t));
      if (t >= 1) {
        progress = 1;
        run = null;
      }
      onProgress(progress);
    }
    let opacity = 1;
    if (back) {
      const t = clamp((now - back.start) / RETURN);
      opacity = easeOut(t);
      if (t >= 1) back = null;
    }
    render(opacity);
    setPhase();
    if ((run || back) && near) frame = requestAnimationFrame(tick);
  };

  const stop = () => {
    cancelAnimationFrame(frame);
    frame = 0;
    run = null;
    back = null;
  };

  // The canvas's box, for sizing its pixels: a resized wipe is drawn again at once.
  const observer = new ResizeObserver(([entry]) => {
    box = {
      width: Math.round(entry.contentRect.width),
      height: Math.round(entry.contentRect.height),
    };
    worded = '';
    if (!run && !back) render();
  });
  observer.observe(canvas);

  // The colour scheme changed: the loader and its words are drawn again in its colours.
  const onScheme = (event: TransitionEvent) => {
    if (event.target !== canvas || event.propertyName !== 'color') return;
    worded = '';
    if (!run && !back) render();
  };
  canvas.addEventListener('transitionend', onScheme);

  return {
    progress: () => progress,
    phase: () => phase,
    /** Plays the wipe from where it is to the end: over `ms` from the start, less from midway. */
    sweep(ms: number) {
      if (progress >= 1 || run) return;
      const length = reduced ? FADE : Math.max(16, ms * (1 - progress));
      back = null;
      run = { from: progress, start: performance.now(), ms: length };
      setPhase();
      cancelAnimationFrame(frame);
      frame = near ? requestAnimationFrame(tick) : 0;
    },
    /** Sets the progress by hand, stopping a sweep. */
    scrub(value: number) {
      stop();
      progress = clamp(value);
      onProgress(progress);
      render();
      setPhase();
    },
    /** Puts the loader back: it fades in over the page. */
    reset() {
      const was = progress;
      stop();
      progress = 0;
      seed = (seed + 1) % 64;
      onProgress(0);
      setPhase();
      // Drawn by the canvas while it has pixels; otherwise the stand-in is simply back.
      if (was > 0 && pixels && near && !reduced) {
        back = { start: performance.now() };
        frame = requestAnimationFrame(tick);
      } else render();
    },
    /** New settings: a wipe on show is drawn again with them. */
    setLook(next: Look) {
      look = next;
      if (!run && !back) render();
    },
    /** Near the screen with the tab visible, the wipe draws; away, it gives its pixels back. */
    setNear(value: boolean) {
      near = value;
      cancelAnimationFrame(frame);
      frame = 0;
      // A fade cut short ends where it was going.
      back = null;
      if (near && run) frame = requestAnimationFrame(tick);
      else render();
    },
    setReduced(value: boolean) {
      reduced = value;
      if (!run && !back) render();
    },
    dispose() {
      stop();
      window.clearTimeout(idle);
      observer.disconnect();
      canvas.removeEventListener('transitionend', onScheme);
      wipe?.dispose();
    },
  };
}

export type Sweep = ReturnType<typeof createSweep>;
```

### loading-wipe/wipe.ts

```tsx
/*
 * The wipe, drawn by WebGL over the whole preview: the loader ahead of the front, the ribbon of
 * colour on it, and nothing behind it, so the page underneath shows through. Two passes:
 *
 * - The field. Every point knows how far along the sweep it lies: 0 where the wipe comes in, 1
 *   where it leaves; the front travels from before 0 to past 1 as the progress goes from 0 to 1.
 *   Fractal value noise, on a domain bent by more noise (swirl), pushes the front back and forth
 *   (turbulence), and near the trailing edge a finer noise holds some points back (bleed), so
 *   colour lingers there in blotches. The result, v, is each point's distance behind the front in
 *   ribbon widths. It is computed on a coarse grid, one value every few CSS pixels, into a small
 *   texture: the expensive noise runs a few thousand times a frame, not a million.
 * - The picture. At full resolution, v is read back from the texture with bilinear filtering, so
 *   the field is smooth, and its contours turn faintly blocky when the turbulence is high. v picks
 *   the colour (the ribbon's palest first, its deepest last) and the edges: the loader ahead, the
 *   ribbon, then its dissolve into the page. The loader is drawn too, with its words (a texture
 *   of the real text, placed where it is) and a fine grain, so the canvas stands in for it
 *   seamlessly while the wipe runs.
 */

/**
 * Pixels per CSS pixel: the screen's, times the scale of the box the wipe sits in (a gallery may
 * draw it shrunk), capped at 2 (sharp enough, for half the memory of 3×).
 */
export const pixelRatio = (scale = 1) => Math.min(2, (window.devicePixelRatio || 1) * scale);

/** The field's grid, in CSS px between two values. */
const CELL = 6;
/** The span of v the field's texture holds (v below it is loader, above it clear page). */
const LOW = -0.5;
const SPAN = 2.1;

export type WipeFrame = {
  /** 0: the loader is whole; 1: the page is clear. */
  progress: number;
  /** Where the wipe travels, in degrees: 0 towards the right, 90 down. */
  angle: number;
  /** The ribbon's four colours, from the deepest (trailing) to the palest (leading). */
  ribbon: readonly [string, string, string, string];
  /** The ribbon and its noise, as the panel sets them (presets.ts says what each one does). */
  spread: number;
  bleed: number;
  feather: number;
  swirl: number;
  turbulence: number;
  scale: number;
  /** The grain's size, in CSS px. */
  grain: number;
  /** Moves the noise to a new place: each sweep looks a little different. */
  seed: number;
  /** The whole picture's opacity: the loader fades back in after a reset. */
  opacity: number;
  /** The loader's surface, its grain averaged in, as 0–1 RGB: the stylesheet's (sweep.ts). */
  loader: number[];
};

/** The loader's words in the preview, in CSS px. */
export type Box = { x: number; y: number; width: number; height: number };

const VERTEX = /* glsl */ `
attribute vec2 aCorner;
void main() {
  gl_Position = vec4(aCorner, 0.0, 1.0);
}
`;

const HASH = /* glsl */ `
float hash(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}
`;

const FIELD = /* glsl */ `
precision highp float;
uniform vec2 uSize;
uniform float uProgress;
uniform vec2 uDirection;
// Spread, bleed, feather, swirl.
uniform vec4 uShape;
// Turbulence, scale, grain size (px), seed.
uniform vec4 uNoise;
${HASH}
// A random value at each corner of a unit grid, blended smoothly in between.
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
    u.y
  );
}

// Up to four layers of noise, each twice as fine and a little over half as strong as the last,
// each drifting its own way.
float fbm(vec2 p, float t, int layers) {
  float sum = 0.0;
  float total = 0.0;
  float amplitude = 1.0;
  for (int i = 0; i < 4; i++) {
    if (i >= layers) break;
    float k = float(i);
    sum += amplitude * noise(p + t * vec2(0.5 - 0.4 * k, 0.3 + 0.25 * k));
    total += amplitude;
    p = p * 2.07 + vec2(5.3, 1.9);
    amplitude *= 0.6;
  }
  return sum / total;
}

void main() {
  // This texel's point, in CSS px from the preview's top left corner (row 0 at the top).
  vec2 p = (gl_FragCoord.xy - 0.5) * ${CELL.toFixed(1)};
  float spread = uShape.x;
  // How far blotches hold back behind the ribbon, and how soft the edges are, in ribbon widths.
  float linger = uShape.y * 0.38;
  float soft = 0.01 + uShape.z * 0.12;
  float turbulence = uNoise.x;
  float seed = uNoise.w;

  // How far along the sweep: 0 where it comes in, 1 where it leaves.
  float extent = abs(uDirection.x) * uSize.x + abs(uDirection.y) * uSize.y;
  float along = dot(p - 0.5 * uSize, uDirection) / extent + 0.5;

  // The noise, on a domain bent by more noise (scale: how many cells span the preview).
  float t = uProgress * 1.6;
  vec2 q = p / max(uSize.x, uSize.y) * uNoise.y + seed;
  vec2 bend = vec2(
    noise(q * 0.5 + vec2(3.1, 7.7) + t * 0.2),
    noise(q * 0.5 + vec2(8.3, 2.8) - t * 0.2)
  );
  q += (bend - 0.5) * uShape.w * 1.6;
  // The front's wobble, in sweep lengths: slow swells that grow with the turbulence, and a finer
  // churn that grows with its cube, faint at first, until it tears the ribbon into lace. Reach:
  // about as far as the wobble ever goes.
  float wave = noise(q * 0.45 + vec2(1.3, 9.1) - t * 0.15) - 0.5;
  float swell = noise(q * 1.1 + vec2(4.7, 2.3) + t * 0.25) - 0.5;
  float churn = fbm(q * 2.3, t, 4) - 0.5;
  float swells = 0.6 * turbulence;
  float lace = 3.1 * turbulence * turbulence * turbulence;
  float wobble = (0.4 * wave + 0.6 * swell) * swells + churn * lace;
  float reach = 0.43 * swells + 0.34 * lace;
  // A finer field for the blotches behind the ribbon.
  float m = fbm(q * 2.6 + vec2(11.3, 4.1), t * 0.7, 2) - 0.5;

  // The front starts before the preview and ends past it, far enough that nothing shows at
  // first and the colour has dissolved at the end (bar the wildest specks, which the last few
  // percent fade out). v: the distance behind the front, in ribbon widths.
  float before = 0.15 + reach + spread * (soft + 0.05);
  float after = spread * (1.25 + linger + soft) + reach;
  float front = mix(-before, 1.0 + after, uProgress);
  float v = (front - along + wobble) / spread;
  // Towards the trailing edge, the finer field holds some points back and lets others go: the
  // edge breaks into blotches that linger, then dissolve as the front moves on.
  v -= linger * m * smoothstep(0.3, 0.9, v);

  gl_FragColor = vec4(clamp((v - ${LOW.toFixed(1)}) / ${SPAN.toFixed(1)}, 0.0, 1.0), 0.0, 0.0, 1.0);
}
`;

const PICTURE = /* glsl */ `
precision highp float;
uniform vec2 uSize;
uniform float uRatio;
uniform vec2 uGrid;
uniform float uProgress;
uniform vec3 uRibbon[4];
uniform vec3 uLoader;
uniform vec4 uShape;
uniform vec4 uNoise;
uniform vec4 uWordsBox;
uniform float uOpacity;
uniform sampler2D uWords;
uniform sampler2D uField;
${HASH}
// The ribbon's colour at v: 0 at the front (the palest), about 1 where it dissolves. The deepest
// colour only tints the dissolve: it never takes more than about two thirds of it.
vec3 ribbon(float v) {
  vec3 colour = mix(uRibbon[3], uRibbon[2], smoothstep(0.02, 0.2, v));
  colour = mix(colour, uRibbon[1], smoothstep(0.24, 0.62, v));
  return mix(colour, uRibbon[0], 0.72 * smoothstep(0.82, 1.06, v));
}

void main() {
  // This pixel, in CSS px from the top left corner.
  vec2 p = vec2(gl_FragCoord.x, uSize.y * uRatio - gl_FragCoord.y) / uRatio;
  float soft = 0.01 + uShape.z * 0.12;

  // The field, between its grid's values.
  float v = texture2D(uField, (p / ${CELL.toFixed(1)} + 0.5) / uGrid).r * ${SPAN.toFixed(1)} + ${LOW.toFixed(1)};

  // The loader and its words.
  vec2 uv = (p - uWordsBox.xy) / uWordsBox.zw;
  vec4 words = texture2D(uWords, clamp(uv, 0.0, 1.0));
  vec3 loader = uLoader * (1.0 - words.a) + words.rgb;

  // Ahead of the front, the loader; then the ribbon, opaque, until it dissolves into the page.
  float body = smoothstep(-soft, soft, v);
  float alpha = 1.0 - smoothstep(0.88 - soft * 0.5, 1.08 + soft * 0.5, v);
  // The last specks go as the sweep ends.
  alpha *= 1.0 - smoothstep(0.95, 1.0, uProgress);

  // A fine grain: on the loader, and faintly on the ribbon, so its gradient never bands.
  float grain = hash(floor(p / uNoise.z) + uNoise.w * 7.0) - 0.5;
  vec3 colour = mix(loader + grain * 0.03, ribbon(v) + grain * 0.012, body);
  gl_FragColor = vec4(colour * alpha, alpha) * uOpacity;
}
`;

/** '#rrggbb' as 0–1 RGB. */
const rgb = (hex: string) => [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
}

/** A program and the places of its uniforms, or null if a shader fails. */
function link<Name extends string>(
  gl: WebGLRenderingContext,
  fragment: string,
  names: readonly Name[],
) {
  const program = gl.createProgram();
  const shaders = [
    compile(gl, gl.VERTEX_SHADER, VERTEX),
    compile(gl, gl.FRAGMENT_SHADER, fragment),
  ];
  if (!program || !shaders[0] || !shaders[1]) return null;
  for (const shader of shaders) if (shader) gl.attachShader(program, shader);
  gl.bindAttribLocation(program, 0, 'aCorner');
  gl.linkProgram(program);
  for (const shader of shaders) if (shader) gl.deleteShader(shader);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program));
    gl.deleteProgram(program);
    return null;
  }
  const at = Object.fromEntries(names.map((name) => [name, gl.getUniformLocation(program, name)]));
  return { program, at: at as Record<Name, WebGLUniformLocation | null> };
}

const FIELD_UNIFORMS = ['uSize', 'uProgress', 'uDirection', 'uShape', 'uNoise'] as const;
const PICTURE_UNIFORMS = [
  'uSize',
  'uRatio',
  'uGrid',
  'uProgress',
  'uRibbon',
  'uLoader',
  'uShape',
  'uNoise',
  'uWordsBox',
  'uWords',
  'uField',
  'uOpacity',
] as const;

export type Wipe = {
  /**
   * Sizes the canvas's pixels to a box of `width` × `height` CSS px, at `ratio` pixels per CSS
   * pixel; 0 × 0 gives them back.
   */
  resize: (width: number, height: number, ratio: number) => void;
  /** The loader's words, drawn at `box`; null for none. */
  words: (image: TexImageSource | null, box: Box) => void;
  /** Draws a frame. False if there is nothing to draw on (no pixels, or WebGL is gone). */
  draw: (frame: WipeFrame) => boolean;
  dispose: () => void;
};

/** The wipe on `canvas`, or null where WebGL is not available. */
export function createWipe(canvas: HTMLCanvasElement): Wipe | null {
  // The edges are smoothed by the shader: no multisampling, no depth.
  const gl = canvas.getContext('webgl', {
    alpha: true,
    premultipliedAlpha: true,
    antialias: false,
    depth: false,
    powerPreference: 'low-power',
  });
  if (!gl) return null;

  let words: { image: TexImageSource | null; box: Box } = {
    image: null,
    box: { x: 0, y: 0, width: 1, height: 1 },
  };
  /** The CSS size the canvas and the field's grid have now. */
  let size = { width: 0, height: 0 };
  let grid = { width: 1, height: 1 };
  let ratio = 1;

  /** Fills the bound texture with the words, or one clear pixel. */
  const uploadWords = () => {
    gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
    if (words.image) {
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, words.image);
    } else {
      const none = new Uint8Array(4);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, none);
    }
  };

  /** Sizes the bound field texture to the grid. */
  const sizeField = () => {
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA,
      grid.width,
      grid.height,
      0,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      null,
    );
  };

  /** A texture, bound, read between its texels and never past its edges. */
  const texture = () => {
    const made = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, made);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    return made;
  };

  const setup = () => {
    const field = link(gl, FIELD, FIELD_UNIFORMS);
    const picture = link(gl, PICTURE, PICTURE_UNIFORMS);
    if (!field || !picture) return null;
    // One triangle that covers the target, for both passes.
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(0);
    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

    // The words on unit 0; the field on unit 1, drawn into through a framebuffer.
    gl.activeTexture(gl.TEXTURE0);
    const wordsTexture = texture();
    uploadWords();
    gl.activeTexture(gl.TEXTURE1);
    const fieldTexture = texture();
    sizeField();
    const framebuffer = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fieldTexture, 0);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);

    gl.useProgram(picture.program);
    gl.uniform1i(picture.at.uWords, 0);
    gl.uniform1i(picture.at.uField, 1);
    return { field, picture, wordsTexture, framebuffer };
  };

  let state = setup();
  if (!state) return null;

  const onLost = (event: Event) => {
    event.preventDefault();
    state = null;
  };
  const onRestored = () => {
    state = setup();
  };
  canvas.addEventListener('webglcontextlost', onLost);
  canvas.addEventListener('webglcontextrestored', onRestored);

  return {
    resize(width, height, pixels) {
      ratio = pixels;
      size = { width, height };
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      // One value per cell, and one more at the far edges.
      grid = {
        width: width ? Math.ceil(width / CELL) + 1 : 1,
        height: height ? Math.ceil(height / CELL) + 1 : 1,
      };
      if (!state) return;
      gl.activeTexture(gl.TEXTURE1);
      sizeField();
    },
    words(image, box) {
      words = { image, box };
      if (!state) return;
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, state.wordsTexture);
      uploadWords();
    },
    draw(frame) {
      if (!state || !canvas.width || !canvas.height) return false;
      const { field, picture, framebuffer } = state;
      const radians = (frame.angle * Math.PI) / 180;
      const shape = [frame.spread, frame.bleed, frame.feather, frame.swirl] as const;
      const noise = [frame.turbulence, frame.scale, frame.grain, frame.seed] as const;

      // The field, on its coarse grid.
      gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
      gl.viewport(0, 0, grid.width, grid.height);
      gl.useProgram(field.program);
      gl.uniform2f(field.at.uSize, size.width, size.height);
      gl.uniform1f(field.at.uProgress, frame.progress);
      gl.uniform2f(field.at.uDirection, Math.cos(radians), Math.sin(radians));
      gl.uniform4f(field.at.uShape, ...shape);
      gl.uniform4f(field.at.uNoise, ...noise);
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      // The picture, at full resolution.
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.useProgram(picture.program);
      const { box } = words;
      gl.uniform2f(picture.at.uSize, size.width, size.height);
      gl.uniform1f(picture.at.uRatio, ratio);
      gl.uniform2f(picture.at.uGrid, grid.width, grid.height);
      gl.uniform1f(picture.at.uProgress, frame.progress);
      gl.uniform3fv(picture.at.uRibbon, frame.ribbon.flatMap(rgb));
      gl.uniform3fv(picture.at.uLoader, frame.loader);
      gl.uniform4f(picture.at.uShape, ...shape);
      gl.uniform4f(picture.at.uNoise, ...noise);
      gl.uniform4f(picture.at.uWordsBox, box.x, box.y, box.width, box.height);
      gl.uniform1f(picture.at.uOpacity, frame.opacity);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      return true;
    },
    dispose() {
      canvas.removeEventListener('webglcontextlost', onLost);
      canvas.removeEventListener('webglcontextrestored', onRestored);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    },
  };
}
```

### loading-wipe/presets.ts

```tsx
/*
 * What the panel tunes: the ribbon's colour presets, the eight sweep directions, and every
 * setting with its range, its default and how it reads (on screen and to a screen reader).
 */

export type PresetId = 'sky' | 'ember' | 'dusk' | 'moss' | 'mono';

export type Preset = {
  id: PresetId;
  name: string;
  /** The ribbon's four colours, from its trailing edge (the deepest) to its leading one. */
  ribbon: readonly [string, string, string, string];
  /** The same four, as a screen reader says them. */
  names: readonly [string, string, string, string];
};

export const PRESETS: readonly Preset[] = [
  {
    id: 'sky',
    name: 'Sky',
    ribbon: ['#5a5cf2', '#22bbf8', '#4ee6fc', '#dff1fe'],
    names: ['indigo', 'sky blue', 'cyan', 'ice'],
  },
  {
    id: 'ember',
    name: 'Ember',
    ribbon: ['#772818', '#fb3a06', '#fbb50f', '#fef2c0'],
    names: ['rust', 'vermilion', 'amber', 'cream'],
  },
  {
    id: 'dusk',
    name: 'Dusk',
    ribbon: ['#33216a', '#7a3cf2', '#c486fd', '#ede7fc'],
    names: ['night', 'violet', 'lilac', 'mist'],
  },
  {
    id: 'moss',
    name: 'Moss',
    ribbon: ['#17522f', '#12a24c', '#84eca7', '#dcf8e6'],
    names: ['pine', 'green', 'mint', 'pale mint'],
  },
  {
    id: 'mono',
    name: 'Mono',
    ribbon: ['#1a1a1d', '#4a4852', '#95939f', '#eeedf2'],
    names: ['black', 'graphite', 'grey', 'pale grey'],
  },
];

export const presetOf = (id: PresetId) => PRESETS.find((preset) => preset.id === id) ?? PRESETS[0];

/** Where the wipe travels: an angle in degrees, 0 towards the right and 90 down. */
export const DIRECTIONS = [
  { angle: 0, label: 'Right' },
  { angle: 45, label: 'Down and right' },
  { angle: 90, label: 'Down' },
  { angle: 135, label: 'Down and left' },
  { angle: 180, label: 'Left' },
  { angle: 225, label: 'Up and left' },
  { angle: 270, label: 'Up' },
  { angle: 315, label: 'Up and right' },
] as const;

export type Settings = {
  preset: PresetId;
  /** The direction of the sweep, in degrees: 0 towards the right, 90 down. */
  angle: number;
  /** How long a sweep takes, in ms. */
  duration: number;
  /** How wide the ribbon is, as a share of the distance the sweep crosses. */
  spread: number;
  /** How far colour lingers behind the ribbon, in blotches, before it dissolves. */
  bleed: number;
  /** How soft the ribbon's edges are. */
  feather: number;
  /** How much the noise curls on itself. */
  swirl: number;
  /** How far, and how finely, the noise pushes the edge about: high, it tears it into lace. */
  turbulence: number;
  /** How many noise cells span the preview: few and big, or many and small. */
  scale: number;
  /** The size of the grain, in px. */
  grain: number;
};

export const DEFAULTS: Settings = {
  preset: 'sky',
  angle: 270,
  duration: 1200,
  spread: 0.65,
  bleed: 0.73,
  feather: 0.27,
  swirl: 0.12,
  turbulence: 0.35,
  scale: 3,
  grain: 1.3,
};

/** A slider's key: one of the settings, or the scrub (the wipe's progress). */
export type SliderKey = Exclude<keyof Settings, 'preset'> | 'scrub';

export type SliderSpec = {
  key: SliderKey;
  label: string;
  min: number;
  max: number;
  step: number;
  /** The value as the panel shows it. */
  format: (value: number) => string;
  /** The value as a screen reader says it. */
  spoken: (value: number) => string;
};

const fixed = (value: number) => value.toFixed(2);

export const SLIDERS: Record<SliderKey, SliderSpec> = {
  angle: {
    key: 'angle',
    label: 'Angle',
    min: 0,
    max: 360,
    step: 1,
    format: (value) => `${value}°`,
    spoken: (value) => `${value} degrees`,
  },
  duration: {
    key: 'duration',
    label: 'Duration',
    min: 300,
    max: 4000,
    step: 100,
    format: (value) => `${value}ms`,
    spoken: (value) => `${value} milliseconds`,
  },
  scrub: {
    key: 'scrub',
    label: 'Scrub',
    min: 0,
    max: 1,
    step: 0.01,
    format: fixed,
    spoken: (value) => `${Math.round(value * 100)}% swept`,
  },
  spread: {
    key: 'spread',
    label: 'Spread',
    min: 0.1,
    max: 1.4,
    step: 0.01,
    format: fixed,
    spoken: fixed,
  },
  bleed: { key: 'bleed', label: 'Bleed', min: 0, max: 1, step: 0.01, format: fixed, spoken: fixed },
  feather: {
    key: 'feather',
    label: 'Feather',
    min: 0,
    max: 1,
    step: 0.01,
    format: fixed,
    spoken: fixed,
  },
  swirl: { key: 'swirl', label: 'Swirl', min: 0, max: 1, step: 0.01, format: fixed, spoken: fixed },
  turbulence: {
    key: 'turbulence',
    label: 'Turbulence',
    min: 0,
    max: 1,
    step: 0.01,
    format: fixed,
    spoken: fixed,
  },
  scale: {
    key: 'scale',
    label: 'Scale',
    min: 1,
    max: 12,
    step: 1,
    format: String,
    spoken: (value) => `${value} cells across`,
  },
  grain: {
    key: 'grain',
    label: 'Size',
    min: 1,
    max: 6,
    step: 0.1,
    format: (value) => `${value.toFixed(1)}px`,
    spoken: (value) => `${value.toFixed(1)} pixels`,
  },
};

/** The panel's sections, in order, and the sliders in each. */
export const SECTIONS = {
  sweep: ['angle', 'duration', 'scrub'],
  ribbon: ['spread', 'bleed', 'feather', 'swirl'],
  noise: ['turbulence', 'scale'],
  grain: ['grain'],
} as const satisfies Record<string, readonly SliderKey[]>;
```

### loading-wipe/use-panel-scroll.ts

```tsx
'use client';

import { useEffect, type RefObject } from 'react';

/*
 * Scrolls the panel by hand. The panel is overflow: hidden, and this moves its scrollTop: the
 * wheel, a vertical touch drag (which coasts a little when let go), and the browser itself when a
 * control inside takes the focus. Why not a native scroll area: Safari on iOS gives each one a
 * layer of its own at full device resolution, which here cost more GPU memory than the whole wipe;
 * a hidden-overflow box is repainted in place instead.
 *
 * At the panel's ends the gesture is left to the page, which scrolls on. While the panel moves, a
 * thin bar over its right edge shows where it is, then fades.
 */

/** How long the bar stays after the panel stops, in ms. */
const LINGER = 700;
/** How far a touch moves before it counts as a drag, in px. */
const SLOP = 6;

type Touch = { x: number; y: number; at: number; vertical: boolean | null; speed: number };

export function usePanelScroll(
  panel: RefObject<HTMLElement | null>,
  bar: RefObject<HTMLElement | null>,
) {
  useEffect(() => {
    const element = panel.current;
    const thumb = bar.current;
    if (!element || !thumb) return;
    let hide = 0;
    let coast = 0;
    let touch: Touch | null = null;
    /** Where the panel is, to the fraction of a pixel (its scrollTop is rounded). */
    let position = 0;

    /** Moves the panel by `delta` px; false if it is already at that end. */
    const move = (delta: number) => {
      const limit = element.scrollHeight - element.clientHeight;
      const next = Math.min(limit, Math.max(0, position + delta));
      if (next === position) return false;
      position = next;
      element.scrollTop = next;
      return true;
    };

    // The bar: its length is the share of the panel in view, its place the share scrolled.
    const show = () => {
      const { scrollTop, scrollHeight, clientHeight } = element;
      if (!touch && !coast) position = scrollTop;
      if (scrollHeight <= clientHeight) return;
      const length = (clientHeight / scrollHeight) * clientHeight;
      const top = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - length);
      thumb.style.height = `${length}px`;
      thumb.style.transform = `translateY(${top}px)`;
      thumb.dataset.shown = '';
      window.clearTimeout(hide);
      hide = window.setTimeout(() => delete thumb.dataset.shown, LINGER);
    };

    const onWheel = (event: WheelEvent) => {
      if (event.ctrlKey) return;
      position = element.scrollTop;
      // Lines and pages to pixels.
      const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientHeight : 1;
      if (move(event.deltaY * unit)) event.preventDefault();
    };

    // Touch: a vertical drag scrolls the panel; a horizontal one is left to the sliders.
    const onTouchStart = (event: TouchEvent) => {
      cancelAnimationFrame(coast);
      coast = 0;
      position = element.scrollTop;
      const point = event.touches[0];
      touch = { x: point.clientX, y: point.clientY, at: event.timeStamp, vertical: null, speed: 0 };
    };
    const onTouchMove = (event: TouchEvent) => {
      if (!touch || event.touches.length > 1) return;
      const point = event.touches[0];
      const dx = point.clientX - touch.x;
      const dy = point.clientY - touch.y;
      if (touch.vertical === null) {
        if (Math.max(Math.abs(dx), Math.abs(dy)) < SLOP) return;
        touch.vertical = Math.abs(dy) > Math.abs(dx);
      }
      if (!touch.vertical) return;
      const elapsed = Math.max(1, event.timeStamp - touch.at);
      touch.speed = 0.8 * (-dy / elapsed) + 0.2 * touch.speed;
      touch.x = point.clientX;
      touch.y = point.clientY;
      touch.at = event.timeStamp;
      if (move(-dy) && event.cancelable) event.preventDefault();
    };
    const onTouchEnd = () => {
      const vertical = touch?.vertical;
      let speed = touch?.speed ?? 0;
      touch = null;
      if (!vertical) return;
      // Let go, it coasts on, slowing down, in px per ms.
      let last = performance.now();
      const step = (now: number) => {
        const elapsed = Math.min(32, Math.max(0, now - last));
        last = now;
        speed *= Math.pow(0.95, elapsed / 16);
        if (Math.abs(speed) < 0.02 || !move(speed * elapsed)) {
          coast = 0;
          return;
        }
        coast = requestAnimationFrame(step);
      };
      coast = requestAnimationFrame(step);
    };

    element.addEventListener('scroll', show, { passive: true });
    element.addEventListener('wheel', onWheel, { passive: false });
    element.addEventListener('touchstart', onTouchStart, { passive: true });
    element.addEventListener('touchmove', onTouchMove, { passive: false });
    element.addEventListener('touchend', onTouchEnd);
    element.addEventListener('touchcancel', onTouchEnd);
    return () => {
      element.removeEventListener('scroll', show);
      element.removeEventListener('wheel', onWheel);
      element.removeEventListener('touchstart', onTouchStart);
      element.removeEventListener('touchmove', onTouchMove);
      element.removeEventListener('touchend', onTouchEnd);
      element.removeEventListener('touchcancel', onTouchEnd);
      window.clearTimeout(hide);
      cancelAnimationFrame(coast);
    };
  }, [panel, bar]);
}
```

### loading-wipe/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 grab a slider's thumb and drag it, 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>;
  /** Scrolls `element` to `top`, smoothly, and waits for it to get there. */
  scroll: (element: HTMLElement, top: number) => 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. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<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);
      }),
    );
  };

  const scroll: Demo['scroll'] = (element, top) => {
    const distance = Math.abs(element.scrollTop - top);
    element.scrollTo({ top, behavior: 'smooth' });
    return wait(distance < 2 ? 0 : 520);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({
    wait,
    moveTo,
    moveToPoint: (point) => glide(toStage(point)),
    press,
    drag,
    scroll,
  }).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';
  };
}

/** The pointer's box: it moves, on a layer of its own. Its colours are the stage's tokens. */
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 var(--pointer-shadow))',
  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 drags. It is decorative:
 * the demo's effects show on the controls themselves.
 */
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"
              style={{ fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' }}
              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"
                style={{ fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' }}
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M8.2 8.1v1.7M10.4 7.9v1.9M12.6 8.6v1.5"
                fill="none"
                style={{ stroke: 'var(--pointer)' }}
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### loading-wipe/icons.tsx

```tsx
/* The icons: drawn on a 16 px grid in the current colour, decorative (their buttons are named). */

/**
 * The sweep button's icon: a play mark (Sweep), or two arrows chasing each other round a circle
 * (Sweeping and Reset). Both are drawn in one SVG and the stylesheet shows one of them: putting a
 * new SVG into the card would make Safari lay out the whole page (see .sweepIcon).
 */
export function SweepIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        data-icon="play"
        d="M4.9 3.5c0-.7.75-1.1 1.33-.73l6.6 4.5c.5.34.5 1.1 0 1.45l-6.6 4.5c-.58.38-1.33-.03-1.33-.73z"
        fill="currentColor"
      />
      <g
        data-icon="loop"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d="M2.2 8a5.8 5.8 0 0 1 10.39-3.55" />
        <path d="M13.1 1.75v2.9h-2.9" />
        <path d="M13.8 8a5.8 5.8 0 0 1-10.39 3.55" />
        <path d="M2.9 14.25v-2.9h2.9" />
      </g>
    </svg>
  );
}

/** A direction: an arrow pointing right, turned by `angle` degrees (clockwise). */
export function ArrowIcon({ angle, className }: { angle: number; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.3"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <g transform={`rotate(${angle} 8 8)`}>
        <path d="M3.6 8h8.6M8.9 4.7 12.2 8l-3.3 3.3" />
      </g>
    </svg>
  );
}
```

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