# Loading tip

> A game’s loading screen: a rotating tip, the run’s progress at a glance, a turning loader, then a Continue prompt.

The screen a game shows while it loads the next region. A cream band holds the run at a glance: two rows of heart containers, stamina wheels, four collected-item counters and the creatures freed so far, standing on the band’s edge, with the tip’s title. The black below holds the advice and a small six-dot loader. Tips rotate while it loads; when it is done, a Continue prompt with the A button breathes until it is pressed and the screen fades to black. The next load ticks its counters up and fills in a newly freed creature. Click the tip or use the arrow keys to read the next one.

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

Category: Games · Screens.

## Anatomy

1. **Band**: The cream band across the top, 426 px of a 1920 × 1080 screen: the run on the right, the tip’s title on the left.
2. **Heart containers**: One heart per container, 16 to a row, top-right of the band.
3. **Stamina wheels**: A ring per whole wheel; the part wheel, drawn as a crescent, comes first on the left.
4. **Item counter**: An italic count and its icon, one per collected item, stacked under the wheels.
5. **Creature row**: The rover and four creatures standing on the band’s edge: the freed ones solid, the rest as 16% ghosts.
6. **Tip title**: The tip’s title, italic, at the foot of the band; it fades out and settles in with each tip.
7. **Advice**: The tip’s body on the black, a button: a click or the arrow keys step to the next tip.
8. **Loader**: Six dots on a ring, turning while each swaps between full and 60% size; it fades once loading is done.

## When to use

- A full-screen wait between regions or levels, long enough (a few seconds or more) to read a line of advice.
- Teaching one mechanic at a time while the player waits, with tips they can step through by hand.
- Showing the run at a glance on the way in: hearts, stamina, collected items and the creatures freed so far.
- A load that should end on the player’s cue, with a Continue prompt instead of dropping them straight into play.
- Marking progress between two loads: counters that tick up and a newly freed creature that fills in.

## When not to use

- A wait under a second or two: the tip cannot be read. Use the loader alone, or nothing.
- Loading inside a menu or a panel that stays on screen. Use an inline spinner or a progress bar.
- A download or an install with a known size. Use a progress bar with a percentage.
- The run’s stats while playing. Use the HUD pieces (heart meter, stamina wheel, coin counter).
- A game over or a chapter end with choices to make. Use the game over screen or a game modal.

## Installation

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

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

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

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

## Usage

```tsx
import { LoadingTip, type LoadingTipItem } from './loading-tip';

const TIPS: LoadingTipItem[] = [
  { title: 'Climbing Tip', body: 'Rest on every ledge: stamina refills only while you stand still.' },
  { title: 'Cooking Tip', body: 'A warming herb in the pot keeps the cold out for a while.' },
];

export function RegionLoader({ loaded, onEnter }: { loaded: boolean; onEnter: () => void }) {
  return (
    <LoadingTip
      tips={TIPS}
      hearts={12}
      stamina={1.5}
      freed={2}
      ready={loaded}
      onContinue={onEnter}
    />
  );
}
```

## States

- **Loading**: `ready={false}`: the loader turns in the corner and the screen is `aria-busy`.
- **Ready**: `ready`: the loader fades and the Continue prompt with the A button slides in and breathes.
- **Another tip**: `defaultTip={2}`: the screen opens on the third tip. Here `tipInterval={0}` holds it.
- **Early run**: Four hearts, one wheel, low counts, no creature freed and no rover: every creature stands as a ghost.
- **Mid run**: `hearts={12}`, `stamina={1.5}`, `freed={2}`: a half wheel drawn as a crescent before the whole one.
- **Late run**: `hearts={40}` wraps to three rows of 16; three full wheels and every creature freed.
- **Own verb**: `continueLabel="Enter"` with a single tip of its own, which then does not rotate.
- **Narrow**: Under 600 px wide the band’s parts stack in flow: the stats, then the creatures, then the title.

## How it works

- The screen is drawn in px of a 1920 × 1080 screen: --u is 100cqw / 1920, a container query unit, and every size is a multiple of it (a 426 px cream band, 19 × 17 hearts 8 px apart in rows of 16, 30 px wheels, 34 px icons 40 px apart, a 43 px title, 31 px advice on a 38 px line), so from 600 px up it scales as one 16:9 picture.
- Below 600 px the band’s parts stop overlapping and stack in flow (stats, creatures, then the title), --u keeps a floor of 0.36 px so the art stays legible, and the text keeps a minimum size; the screen grows as tall as it needs.
- All the art is SVG in currentColor with cut-outs in the band’s cream: hearts, wheels (a part-filled wheel is the left arc of the ring, so half a wheel reads as a crescent), an orb, a shard, a seed pod and a gem, and five creatures built from plain shapes, each placed along one ground line in px of the screen.
- Tips rotate every tipInterval ms while loading: the old title and advice fade and lift in 200 ms, then the new title settles in and its advice follows 70 ms later. Clicking the tip, or the arrow keys on it, steps through them by hand, and a polite live region reads the new tip.
- The loader is six dots on a ring, alternately big and small: the ring turns once every 2.4 s while each dot swaps between full and 60% size, out of phase with its neighbours, so the big dots seem to travel round.
- When loading is done (ready, or uncontrolled after loadTime), the loader fades and the Continue prompt slides in with an overshoot, its verb and button glowing every 1.8 s. Pressing it (a click, Enter, Space, or A anywhere in the screen) dips the button with a ring of light while a black overlay fades in over 420 ms; onContinue is called once the screen is black.
- Each load is a new screen: it fades up from black, and its progress plays against the screen before it. A counter that went up counts to its new value with an ease-out (shown by script, with the real value for screen readers), and its icon pops; a newly freed creature fills in from its ghost, swells and settles, with a warm flash behind it.
- The demo, while it is on screen and untouched, plays two loads in a row: the first from a slightly earlier run, the second at the props, so each round shows a tip change, the prompt, the press, the fade and the progress. Any input hands it to the reader.
- The screen keeps its own cream and black on a light or a dark page; only its edge and its shadow on the page are light-dark() tokens.

## API reference

- **tips** (`LoadingTipItem[]`, default `five tips`): The tips it rotates through, each { title, body }.
- **defaultTip** (`number`, default `0`): The index of the tip it starts on.
- **tipInterval** (`number`, default `6000`): Ms between tips while loading; 0 keeps one tip. Rotation stops once it is ready.
- **hearts** (`number`, default `32`): Heart containers, drawn 16 to a row.
- **stamina** (`number`, default `2.5`): Stamina in wheels: whole rings, plus a crescent for the fraction.
- **counters** (`LoadingCounter[]`, default `orbs 7, shards 69, seeds 420, gems 80085`): The collected-item counters, top to bottom, each { id, label, value, icon } with icon 'orb', 'shard', 'seed' or 'gem'. A value that went up since the last screen ticks up.
- **freed** (`number`, default `4`): How many of the four creatures are freed and drawn solid; the rest stand as faint ghosts. One freed since the last screen fills in with a flash.
- **rover** (`boolean`, default `true`): Shows the small rover at the start of the creature row.
- **ready** (`boolean`): Loading is done (controlled): the loader gives way to the Continue prompt. Without it, the component finishes on its own after loadTime.
- **loadTime** (`number`, default `9000`): Uncontrolled, how long each load takes, in ms.
- **continueLabel** (`string`, default `'Continue'`): The verb beside the A button.
- **onContinue** (`() => void`): Called when Continue is pressed, once the screen has faded to black.
- **autoplay** (`boolean`, default `true`): Plays two loads in a row while on screen and untouched (uncontrolled only); any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Sets color-scheme on the root, for the screen’s edge and shadow; the screen keeps its own colours.
- **className** (`string`): Added to the root.

## Accessibility

- The screen is a section labelled Loading screen, aria-busy while it loads. The tip’s title is a heading; its advice is a button (Next tip) that the arrow keys step through, and a polite live region reads a tip the reader moved to, and says when loading is done.
- The progress is a description list: heart containers, stamina wheels and each counter by name, with the real value in text even while the shown number ticks. The creatures are a list labelled with how many are freed, each named, the ghosts marked not freed yet. The art itself is aria-hidden.
- Continue is a real button, reached by Tab and pressed by Enter or Space; A presses it while the focus is in the screen. If it had the focus when it goes, the focus moves to the tip.
- Every control shows a focus ring. Under reduced motion there is no demo, the fades, tip transitions, fill-ins and glows are off, and the loader stops turning and only pulses slowly; the tips still change, without movement.

## Source

### loading-tip/loading-tip.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { CREATURES, Heart, Icon, ROVER, ROW, Wheel, type CounterIcon } from './art';
import { useAutoplay } from './use-autoplay';
import styles from './loading-tip.module.css';

/*
 * Loading tip: the screen a game shows while it loads the next region. A cream band holds the
 * run's progress at a glance (heart containers, stamina wheels, four collected-item counters and
 * the creatures freed so far, standing on the band's edge) and a tip's title; the black below
 * holds the tip's advice and a small loader turning in the corner.
 *
 * - The screen is drawn in px of a 1920 × 1080 screen (--u, one such px, is 1/1920 of its width),
 *   so it scales as one picture at 16:9. Below 600 px it stops being a fixed picture: the band's
 *   parts stack, the art keeps a readable minimum size and the text a minimum font size.
 * - While it loads, the tips rotate every `tipInterval` ms: the old one fades and lifts, the new
 *   title settles in, its advice a beat later. Clicking the tip, or the arrow keys on it, steps
 *   through them by hand.
 * - Progress since the last screen plays as the screen comes up: a counter that went up ticks to
 *   its value, a newly freed creature fills in from its ghost with a flash.
 * - When loading is done (`ready`, or uncontrolled after `loadTime`), the loader fades and a
 *   Continue prompt with the A button slides in and breathes. Pressing it (click, Enter, Space or
 *   A) fades the screen to black and calls `onContinue`; uncontrolled, the next load starts.
 * - The demo, while it is on screen and untouched, plays two loads in a row: the second one with
 *   counters gone up and the last creature freed.
 */

export type LoadingTipItem = { title: string; body: string };
export type LoadingCounter = { id: string; label: string; value: number; icon: CounterIcon };
export type { CounterIcon };

export const DEFAULT_TIPS: LoadingTipItem[] = [
  {
    title: 'Climbing Tip',
    body: 'The stamina needed to climb grows with the steepness of the wall. To climb higher on less stamina, look for faces with a gentler slope and rest on every ledge.',
  },
  {
    title: 'Cooking Tip',
    body: 'Food cooked with a warming herb keeps the cold out for a while. The more of it you add to the pot, the longer the effect lasts on a snowy ridge.',
  },
  {
    title: 'Weather Tip',
    body: 'Rain makes every rock slippery, so climbing is slow going in a storm. Find shelter and wait it out by a fire, or take the long way round.',
  },
  {
    title: 'Gliding Tip',
    body: 'Jump from high ground and open your glider to cross a valley in one go. Rising warm air over a fire lifts you higher if you circle above it.',
  },
  {
    title: 'Riding Tip',
    body: 'A mount you have calmed many times trusts you more. Soothe it often while riding and it will follow the road on its own.',
  },
];

export const DEFAULT_COUNTERS: LoadingCounter[] = [
  { id: 'orbs', label: 'Spirit orbs', value: 7, icon: 'orb' },
  { id: 'shards', label: 'Shards', value: 69, icon: 'shard' },
  { id: 'seeds', label: 'Seed pods', value: 420, icon: 'seed' },
  { id: 'gems', label: 'Gems', value: 80085, icon: 'gem' },
];

/** What the first of the demo's two loads starts from: the second one is the props. */
const DEMO_BEFORE: Record<string, number> = { orbs: 5, shards: 61, seeds: 388, gems: 79410 };

const HEARTS_PER_ROW = 16;
/** How long a tip takes to leave, and a screen to fade to black. */
const TIP_OUT = 200;
const FADE_OUT = 420;
const TICK = 900;
/** In the demo, when a load is done and when the prompt is pressed, from the screen's start. */
const DEMO_READY = 7400;
const DEMO_PRESS = 9800;

export type LoadingTipProps = {
  className?: string;
  /** The tips it rotates through. */
  tips?: LoadingTipItem[];
  /** The tip it starts on. */
  defaultTip?: number;
  /** Ms between tips while loading; 0 keeps one tip. */
  tipInterval?: number;
  /** Heart containers, 16 to a row. */
  hearts?: number;
  /** Stamina wheels, in wheels: 2.5 is two whole rings and a half crescent. */
  stamina?: number;
  /** The collected-item counters, top to bottom. */
  counters?: LoadingCounter[];
  /** How many of the four creatures are freed (solid); the rest stand as ghosts. */
  freed?: number;
  /** Shows the small rover at the start of the creature row. */
  rover?: boolean;
  /** Loading is done: the loader gives way to the Continue prompt (controlled). */
  ready?: boolean;
  /** Uncontrolled, how long each load takes, in ms. */
  loadTime?: number;
  /** The prompt's verb. */
  continueLabel?: string;
  /** Called when the reader presses Continue, once the screen has faded to black. */
  onContinue?: () => void;
  /** Plays two loads in a row while on screen and untouched (uncontrolled only). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

/** Counts from `from` to `to` over TICK ms (ease-out), once, when `to` changes. */
function useTicker(to: number, from: number | null) {
  const [shown, setShown] = useState(to);
  useEffect(() => {
    if (from === null || from === to || matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setShown(to);
      return;
    }
    let frame = 0;
    const start = performance.now() + 350;
    const step = (now: number) => {
      const t = Math.min(1, Math.max(0, (now - start) / TICK));
      const eased = 1 - (1 - t) ** 3;
      setShown(Math.round(from + (to - from) * eased));
      if (t < 1) frame = requestAnimationFrame(step);
    };
    setShown(from);
    frame = requestAnimationFrame(step);
    return () => cancelAnimationFrame(frame);
  }, [to, from]);
  return shown;
}

function Counter({ counter, from }: { counter: LoadingCounter; from: number | null }) {
  const shown = useTicker(counter.value, from);
  const rising = from !== null && from < counter.value;
  return (
    <div className={styles.counter} data-part="counter">
      <dt className={styles.hidden}>{counter.label}</dt>
      <dd className={styles.count}>
        <span className={styles.number} aria-hidden="true" data-part="number">
          {shown}
        </span>
        <span className={styles.hidden}>{counter.value}</span>
        <span className={styles.icon} data-rising={rising || undefined} data-part="icon">
          <Icon icon={counter.icon} />
        </span>
      </dd>
    </div>
  );
}

export function LoadingTip({
  className,
  tips = DEFAULT_TIPS,
  defaultTip = 0,
  tipInterval = 6000,
  hearts = 32,
  stamina = 2.5,
  counters = DEFAULT_COUNTERS,
  freed = 4,
  rover = true,
  ready: readyProp,
  loadTime = 9000,
  continueLabel = 'Continue',
  onContinue,
  autoplay = true,
  colorScheme,
}: LoadingTipProps) {
  const controlled = readyProp !== undefined;
  const { ref, take, playing } = useAutoplay<HTMLDivElement>(8000);
  const demo = playing && autoplay && !controlled;

  const count = Math.max(1, tips.length);
  const [tip, setTip] = useState(() => Math.min(Math.max(0, defaultTip), count - 1));
  const [leaving, setLeaving] = useState(false);
  const [ownReady, setOwnReady] = useState(false);
  const ready = controlled ? readyProp : ownReady;
  // Each load is a new screen: it fades up from black, and its progress plays against `before`.
  const [screen, setScreen] = useState(0);
  const [fading, setFading] = useState(false);
  const [before, setBefore] = useState<{ counters: Record<string, number>; freed: number } | null>(
    null,
  );
  // The demo's first load shows the run a little earlier; its second load is the props.
  const [early, setEarly] = useState(false);
  const [said, setSaid] = useState('');
  const timers = useRef<number[]>([]);
  const tipRef = useRef<HTMLButtonElement>(null);
  const later = useCallback((ms: number, fn: () => void) => {
    timers.current.push(window.setTimeout(fn, ms));
  }, []);
  useEffect(() => () => timers.current.forEach((id) => window.clearTimeout(id)), []);

  const shownCounters = early
    ? counters.map((c) => ({ ...c, value: DEMO_BEFORE[c.id] ?? c.value }))
    : counters;
  const shownFreed = Math.max(0, Math.min(CREATURES.length, early ? freed - 1 : freed));

  // Steps to another tip: the old one leaves, then the new one comes in.
  const goToTip = useCallback(
    (next: number, announce = false) => {
      const index = ((next % count) + count) % count;
      setLeaving(true);
      later(TIP_OUT, () => {
        setTip(index);
        setLeaving(false);
        if (announce) setSaid(`${tips[index]?.title}. ${tips[index]?.body}`);
      });
    },
    [count, later, tips],
  );

  // Tips rotate while loading.
  const rotate = useEffectEvent(() => goToTip(tip + 1));
  useEffect(() => {
    if (!tipInterval || count < 2 || ready || fading) return;
    const id = window.setInterval(rotate, tipInterval);
    return () => window.clearInterval(id);
  }, [tipInterval, count, ready, fading, screen]);

  // Uncontrolled, a load takes loadTime; then the prompt waits for the reader.
  useEffect(() => {
    if (controlled || ownReady || fading || demo) return;
    const id = window.setTimeout(() => setOwnReady(true), loadTime);
    return () => window.clearTimeout(id);
  }, [controlled, ownReady, fading, demo, loadTime, screen]);

  useEffect(() => {
    setSaid(ready ? `Loaded. Press A to ${continueLabel.toLowerCase()}.` : '');
  }, [ready, continueLabel]);

  // Pressing Continue: the screen fades to black, then (uncontrolled) the next load comes up.
  const proceed = useCallback(
    (fromDemo = false) => {
      if (!ready || fading) return;
      setFading(true);
      later(FADE_OUT, () => {
        // The prompt is about to go: if it had the focus, the tip takes it.
        const active = document.activeElement;
        if (active instanceof HTMLElement && active.dataset.part === 'continue')
          tipRef.current?.focus({ preventScroll: true });
        onContinue?.();
        // The demo alternates the earlier run and the props; going back to the earlier run is a
        // replay, so nothing ticks then. Otherwise the next screen plays its progress.
        const toEarly = fromDemo && !early;
        setBefore(
          toEarly
            ? null
            : {
                counters: Object.fromEntries(shownCounters.map((c) => [c.id, c.value])),
                freed: shownFreed,
              },
        );
        setEarly(toEarly);
        if (!controlled) setOwnReady(false);
        setTip((now) => (now + 1) % count);
        setLeaving(false);
        setScreen((now) => now + 1);
        setFading(false);
      });
    },
    [ready, fading, later, onContinue, early, shownCounters, shownFreed, controlled, count],
  );

  // The demo: each load shows a tip change (the rotation's own), then ready, then a press.
  const demoStep = useEffectEvent((step: 'ready' | 'press') => {
    if (step === 'ready') setOwnReady(true);
    else proceed(true);
  });
  const startDemo = useEffectEvent(() => {
    // It opens on the earlier run, so its second load has progress to show; the screen comes up
    // again from black so the switch is not seen.
    if (early || before !== null) return;
    setEarly(true);
    setScreen((now) => now + 1);
  });
  useEffect(() => {
    if (!demo) return;
    startDemo();
    const ids = [
      window.setTimeout(() => demoStep('ready'), DEMO_READY),
      window.setTimeout(() => demoStep('press'), DEMO_PRESS),
    ];
    return () => ids.forEach((id) => window.clearTimeout(id));
  }, [demo, screen]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    if (event.key === 'a' || event.key === 'A') {
      if (!ready) return;
      event.preventDefault();
      take();
      proceed();
    }
  };

  const onTipKey = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
      event.preventDefault();
      take();
      goToTip(tip + 1, true);
    } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
      event.preventDefault();
      take();
      goToTip(tip - 1, true);
    }
  };

  const current = tips[tip] ?? { title: '', body: '' };
  const fullWheels = Math.floor(stamina);
  const partWheel = stamina - fullWheels;
  const heartCount = Math.max(0, Math.round(hearts));
  const columns = Math.min(HEARTS_PER_ROW, Math.max(1, heartCount));

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-part="root"
      onPointerDown={take}
      onKeyDown={onKeyDown}
    >
      <section
        className={styles.screen}
        aria-label="Loading screen"
        aria-busy={!ready}
        data-part="screen"
        style={{ '--columns': columns } as CSSProperties}
      >
        <div className={styles.band} data-part="band">
          <dl className={styles.stats} aria-label="Progress" data-part="stats">
            <div className={styles.hearts} data-part="hearts">
              <dt className={styles.hidden}>Heart containers</dt>
              <dd>
                <span className={styles.hidden}>{heartCount}</span>
                <span className={styles.heartGrid} aria-hidden="true">
                  {Array.from({ length: heartCount }, (_, i) => (
                    <span key={i} className={styles.heart} data-part="heart">
                      <Heart />
                    </span>
                  ))}
                </span>
              </dd>
            </div>
            <div className={styles.wheels} data-part="stamina">
              <dt className={styles.hidden}>Stamina wheels</dt>
              <dd>
                <span className={styles.hidden}>{stamina}</span>
                <span className={styles.wheelRow} aria-hidden="true">
                  {partWheel > 0.01 && (
                    <span className={styles.wheel} data-part="wheel">
                      <Wheel fill={partWheel} />
                    </span>
                  )}
                  {Array.from({ length: fullWheels }, (_, i) => (
                    <span key={i} className={styles.wheel} data-part="wheel">
                      <Wheel fill={1} />
                    </span>
                  ))}
                </span>
              </dd>
            </div>
            {shownCounters.map((counter) => (
              <Counter
                key={`${counter.id}:${screen}`}
                counter={counter}
                from={before ? (before.counters[counter.id] ?? null) : null}
              />
            ))}
          </dl>

          <ul
            className={styles.creatures}
            aria-label={`Creatures freed: ${shownFreed} of ${CREATURES.length}`}
            data-part="creatures"
            style={{ '--row-w': ROW.w, '--row-h': ROW.h } as CSSProperties}
          >
            {rover && (
              <li
                className={styles.creature}
                style={{ '--x': ROVER.x, '--w': ROVER.w, '--h': ROVER.h } as CSSProperties}
                data-rover=""
                aria-label="Rover"
              >
                <svg viewBox={`0 0 ${ROVER.w} ${ROVER.h}`} aria-hidden="true">
                  <ROVER.Art />
                </svg>
              </li>
            )}
            {CREATURES.map(({ id, label, x, w, h, Art }, i) => {
              const isFreed = i < shownFreed;
              const isNew = isFreed && before !== null && i >= before.freed;
              return (
                <li
                  key={`${id}:${screen}`}
                  className={styles.creature}
                  style={{ '--x': x, '--w': w, '--h': h } as CSSProperties}
                  data-freed={isFreed || undefined}
                  data-new={isNew || undefined}
                  aria-label={`${label}${isFreed ? '' : ', not freed yet'}`}
                  data-part="creature"
                >
                  <svg viewBox={`0 0 ${w} ${h}`} aria-hidden="true">
                    <Art />
                  </svg>
                </li>
              );
            })}
          </ul>

          <h3
            className={styles.title}
            key={`t${tip}:${screen}`}
            data-leaving={leaving || undefined}
            data-part="tip-title"
          >
            {current.title}
          </h3>
        </div>

        <div className={styles.night} data-part="lower">
          <button
            type="button"
            ref={tipRef}
            className={styles.tip}
            aria-label={`${current.title}. ${current.body}`}
            aria-keyshortcuts="ArrowLeft ArrowRight"
            title={count > 1 ? 'Next tip' : undefined}
            onClick={() => {
              take();
              if (count > 1) goToTip(tip + 1, true);
            }}
            onKeyDown={onTipKey}
            data-part="tip-body"
          >
            <span
              className={styles.body}
              key={`b${tip}:${screen}`}
              data-leaving={leaving || undefined}
            >
              {current.body}
            </span>
          </button>

          <div className={styles.foot}>
            <span
              className={styles.loader}
              data-done={ready || undefined}
              aria-hidden="true"
              data-part="loader"
            >
              {Array.from({ length: 6 }, (_, i) => (
                <span key={i} className={styles.dot} style={{ '--i': i } as CSSProperties} />
              ))}
            </span>
            {ready && (
              <button
                type="button"
                className={styles.continue}
                data-going={fading || undefined}
                aria-keyshortcuts="A"
                onClick={() => {
                  take();
                  proceed();
                }}
                data-part="continue"
              >
                <span className={styles.verb} data-part="verb">
                  {continueLabel}
                </span>
                <span className={styles.glyph} aria-hidden="true" data-part="glyph">
                  <svg viewBox="0 0 32 32">
                    <circle cx="16" cy="16" r="16" fill="var(--glyph)" />
                    <text
                      x="16"
                      y="22.8"
                      textAnchor="middle"
                      fontSize="19"
                      fill="var(--glyph-ink)"
                      style={{ fontWeight: 600, fontStyle: 'normal' }}
                    >
                      A
                    </text>
                  </svg>
                </span>
              </button>
            )}
          </div>
        </div>

        <span
          className={styles.fade}
          key={`f${screen}`}
          data-out={fading || undefined}
          aria-hidden="true"
        />
      </section>
      <p className={styles.hidden} aria-live="polite">
        {said}
      </p>
    </div>
  );
}
```

### loading-tip/loading-tip.module.css

```css
/* Loading tip (loading-tip.tsx): a whole loading screen, centred on the plain stage. It is drawn
   in --u, one px of a 1920 × 1080 screen (1/1920 of the screen's width, a container query unit),
   so at 600 px and up it is one 16:9 picture: the cream band is 426 px of it, the black the rest.
   Below 600 px the band's parts stack in flow instead, --u keeps a floor so the art stays legible,
   and the text keeps a minimum size.

   The screen keeps its own colours on a light or a dark page (it is a game's screen, cream and
   black); only its edge and its shadow on the page follow the colour scheme, through light-dark()
   tokens. Art is SVG in currentColor with cut-outs in --band, so it stays sharp at any density.

   Motion: the screen fades up from black and back down (an overlay's opacity); the loader turns
   (a transform) while its dots swap sizes; a tip leaves by a transition and arrives by keyframes;
   a newly freed creature fills in and flashes; counters tick by script. */

.root {
  /* The screen. */
  --band: #fffde4;
  --ink: #000;
  --night: #000;
  --tip-ink: rgb(255 255 255 / 0.9);
  --dot: #e2ded3;
  --glyph: #e1ded1;
  --glyph-ink: #24221d;
  --verb: #e0ded1;
  --glow: rgb(255 246 214 / 0.7);
  --flash: rgb(255 190 80 / 0.75);
  /* The screen on the page. */
  --edge: rgb(0 0 0 / 0.06);
  --shade: rgb(20 18 12 / 0.18);
  --focus: #8db4ff;
  position: relative;
  container: stage / inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 360px;
  padding: 28px 16px;
  box-sizing: border-box;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.12));
    --shade: light-dark(rgb(20 18 12 / 0.18), rgb(0 0 0 / 0.5));
  }
}
.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;
}

.screen {
  position: relative;
  container: screen / inline-size;
  display: grid;
  grid-template-rows: auto 1fr;
  width: 100%;
  max-width: 1280px;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--night);
  box-shadow:
    0 0 0 1px var(--edge),
    0 10px 36px var(--shade);
}

/* ——— The band ——— */

.band {
  --u: calc(100cqw / 1920);
  position: relative;
  height: calc(426 * var(--u));
  background: var(--band);
  color: var(--ink);
}

.stats {
  position: absolute;
  top: calc(50 * var(--u));
  right: calc(73 * var(--u));
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  margin: 0;
}
.stats dd {
  margin: 0;
}

.hearts {
  margin-right: calc(11 * var(--u));
}
.heartGrid {
  display: grid;
  grid-template-columns: repeat(var(--columns), calc(19 * var(--u)));
  gap: calc(8 * var(--u));
}
.heart {
  display: block;
  width: calc(19 * var(--u));
  height: calc(17 * var(--u));
}
.heart svg,
.wheel svg,
.icon svg,
.creature svg,
.loader svg,
.glyph svg {
  display: block;
  width: 100%;
  height: 100%;
}

.wheels {
  margin-top: calc(14 * var(--u));
  margin-right: calc(5 * var(--u));
}
.wheelRow {
  display: flex;
  gap: calc(4.5 * var(--u));
}
.wheel {
  display: block;
  width: calc(30 * var(--u));
  height: calc(30 * var(--u));
}

.counter {
  display: flex;
  height: calc(40 * var(--u));
}
.wheels + .counter {
  margin-top: calc(3 * var(--u));
}
.count {
  display: flex;
  align-items: center;
  gap: calc(6 * var(--u));
}
.number {
  font-size: calc(27 * var(--u));
  font-style: italic;
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
  line-height: 1;
  color: #141412;
}
.icon {
  display: block;
  width: calc(34 * var(--u));
  height: calc(34 * var(--u));
}
.icon[data-rising] {
  animation: pop 420ms cubic-bezier(0.2, 0.9, 0.3, 1.4) 1250ms both;
}
@keyframes pop {
  40% {
    scale: 1.22;
  }
}

/* The creatures: a 590 × 120 row standing on the band's foot, 10 px above its edge. */
.creatures {
  position: absolute;
  right: calc(75 * var(--u));
  bottom: calc(9 * var(--u));
  width: calc(var(--row-w) * var(--u));
  height: calc(var(--row-h) * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
}
.creature {
  position: absolute;
  bottom: 0;
  left: calc(var(--x) * var(--u));
  width: calc(var(--w) * var(--u));
  height: calc(var(--h) * var(--u));
  opacity: 0.16;
  transform-origin: 50% 100%;
}
.creature[data-freed],
.creature[data-rover] {
  opacity: 1;
}
.creature[data-rover] {
  bottom: calc(-5 * var(--u));
  width: calc(var(--w) * 1.07 * var(--u));
  height: calc(var(--h) * 1.07 * var(--u));
}
/* A newly freed creature fills in from its ghost, swells and settles, with a warm flash. */
.creature[data-new] {
  animation: freed 900ms cubic-bezier(0.2, 0.8, 0.3, 1) 700ms both;
}
.creature[data-new]::before {
  content: '';
  position: absolute;
  inset: -40%;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--flash), transparent);
  opacity: 0;
  animation: flash 1100ms ease-out 760ms;
}
@keyframes freed {
  from {
    opacity: 0.16;
  }
  35% {
    opacity: 1;
    scale: 1.12;
  }
  65% {
    scale: 0.97;
  }
  to {
    opacity: 1;
    scale: 1;
  }
}
@keyframes flash {
  20% {
    opacity: 1;
  }
}

/* The tip's title, at the band's foot on the left. */
.title {
  position: absolute;
  left: calc(77 * var(--u));
  bottom: calc(14 * var(--u));
  margin: 0;
  font-size: max(calc(43 * var(--u)), 15px);
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--ink);
  animation: tip-in 380ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

/* ——— The black ——— */

.night {
  --u: calc(100cqw / 1920);
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: calc(16 * var(--u)) calc(76 * var(--u)) calc(51 * var(--u)) calc(77 * var(--u));
}

.tip {
  display: block;
  width: 100%;
  max-width: max(calc(950 * var(--u)), 15em);
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--tip-ink);
  font: inherit;
  font-size: max(calc(31 * var(--u)), 12px);
  font-style: italic;
  font-weight: 500;
  line-height: 1.225;
  text-align: start;
  cursor: pointer;
}
.tip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
  border-radius: 2px;
}
.body {
  display: block;
  animation: tip-in 420ms cubic-bezier(0.2, 0.8, 0.3, 1) 70ms both;
}
.title[data-leaving],
.body[data-leaving] {
  opacity: 0;
  translate: 0 -0.25em;
  transition:
    opacity 200ms ease-in,
    translate 200ms ease-in;
  animation: none;
}
@keyframes tip-in {
  from {
    opacity: 0;
    translate: 0 0.3em;
  }
}

/* The foot: the loader on the left, the Continue prompt on the right, on one line. */
.foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-top: auto;
  min-height: calc(34 * var(--u));
  padding-left: calc(1 * var(--u));
}

/* Six dots on a ring: it turns while the dots swap big and small in turn. */
.loader {
  position: relative;
  flex: none;
  width: max(calc(34 * var(--u)), 16px);
  aspect-ratio: 1;
  animation: turn 2400ms linear infinite;
  transition: opacity 300ms;
}
.loader[data-done] {
  opacity: 0;
  animation-play-state: paused;
}
.dot {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 28%;
  aspect-ratio: 1;
  margin: -14% 0 0 -14%;
  transform: rotate(calc(var(--i) * 60deg + 30deg)) translateY(-150%);
}
.dot::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--dot);
  animation: swap 1200ms ease-in-out infinite;
  animation-delay: calc(var(--i) * -600ms);
}
@keyframes turn {
  to {
    rotate: 1turn;
  }
}
@keyframes swap {
  0%,
  100% {
    scale: 1;
  }
  50% {
    scale: 0.6;
  }
}

/* Continue: the verb and the A button, sliding in and then breathing. */
.continue {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  margin: 0 0 0 auto;
  padding: 0.2em 0.3em 0.2em 0.6em;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--verb);
  font: inherit;
  font-size: max(calc(31 * var(--u)), 13px);
  line-height: 1;
  cursor: pointer;
  animation: prompt-in 420ms cubic-bezier(0.2, 0.9, 0.25, 1.18) both;
}
.continue:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.verb {
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.01em;
  animation: breathe 1800ms ease-in-out 500ms infinite;
}
.glyph {
  display: block;
  width: 1.185em;
  height: 1.185em;
  border-radius: 50%;
  animation: halo 1800ms ease-in-out 500ms infinite;
}
@media (hover: hover) {
  .continue:hover .verb {
    color: #f6f3e6;
  }
}
.continue:active .glyph {
  scale: 0.86;
}
/* Pressed: the button dips and springs back with a ring of light as the screen goes dark. */
.continue[data-going] .glyph {
  animation: dip 380ms cubic-bezier(0.3, 0.7, 0.4, 1.5);
  box-shadow: 0 0 0 0.12em var(--glow);
  transition: box-shadow 380ms;
}
.continue[data-going] .verb {
  animation: none;
  color: #f6f3e6;
  text-shadow: 0 0 0.35em var(--glow);
}
@keyframes dip {
  30% {
    scale: 0.8;
  }
}
@keyframes prompt-in {
  from {
    opacity: 0;
    translate: 0.8em 0;
  }
  45% {
    opacity: 1;
  }
}
@keyframes breathe {
  50% {
    text-shadow: 0 0 0.35em var(--glow);
  }
}
@keyframes halo {
  50% {
    box-shadow: 0 0 0.45em var(--glow);
  }
}

/* The fade to and from black, over the whole screen. */
.fade {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: #000;
  opacity: 0;
  pointer-events: none;
  animation: fade-up 520ms ease-out;
  transition: opacity 420ms ease-in;
}
.fade[data-out] {
  opacity: 1;
  animation: none;
}
@keyframes fade-up {
  from {
    opacity: 1;
  }
}

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

/* ——— Narrow: the band's parts stack, the art keeps a floor ——— */

@container stage (max-width: 599px) {
  .screen {
    aspect-ratio: auto;
    grid-template-rows: auto minmax(170px, auto);
  }
  .band,
  .night {
    --u: max(calc(100cqw / 1920), 0.36px);
  }
  .band {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 12px;
    height: auto;
    padding: 16px 14px 0;
  }
  .stats,
  .creatures,
  .title {
    position: relative;
    inset: auto;
  }
  .number {
    font-size: max(calc(27 * var(--u)), 11px);
  }
  .counter {
    height: max(calc(40 * var(--u)), 16px);
  }
  .creatures {
    margin-bottom: -2px;
  }
  .title {
    align-self: flex-start;
    margin-top: -6px;
    padding-bottom: 6px;
  }
  .night {
    padding: 12px 14px 16px;
  }
  .foot {
    padding-top: 16px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .title,
  .body,
  .icon[data-rising],
  .creature[data-new],
  .creature[data-new]::before,
  .continue,
  .verb,
  .glyph,
  .continue[data-going] .glyph,
  .fade {
    animation: none;
  }
  .title[data-leaving],
  .body[data-leaving],
  .fade {
    transition: none;
  }
  .loader {
    animation: none;
  }
  .dot::after {
    animation-duration: 3000ms;
  }
}
```

### loading-tip/art.tsx

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

/*
 * The loading screen's art, drawn as SVG in px of a 1920 × 1080 screen (the CSS sizes each piece
 * in --u, one such px). Everything is flat ink (currentColor) with a few cut-outs in the band's
 * cream (--band), as stamped silhouettes are:
 *
 * - Heart: a 19 × 17 heart. Wheel: a 30 px ring, 7.5 px thick; a partial wheel is the left part
 *   of that ring, so less than a whole wheel reads as a crescent.
 * - Counter icons, 34 px: a dark orb with a cream fan, a diamond shard, a seed pod, a long gem.
 * - The creatures, standing on one ground line: a small rover, a rising hawk, a stilt beast with
 *   a crest, a skink with a curled tail, a shelled tortoise. Each is a group of plain shapes.
 */

export type CounterIcon = 'orb' | 'shard' | 'seed' | 'gem';

export function Heart() {
  return (
    <svg viewBox="0 0 19 17" aria-hidden="true">
      <path d="M9.5 17 1.4 8.6A4.7 4.7 0 0 1 9.5 2.5a4.7 4.7 0 0 1 8.1 6.1Z" fill="currentColor" />
    </svg>
  );
}

/** One stamina wheel, `fill` from 0 to 1 (1 is a whole ring). */
export function Wheel({ fill }: { fill: number }) {
  const r = 11.25;
  if (fill >= 1)
    return (
      <svg viewBox="0 0 30 30" aria-hidden="true">
        <circle cx="15" cy="15" r={r} fill="none" stroke="currentColor" strokeWidth="7.5" />
      </svg>
    );
  // The left part of the ring, centred on 9 o'clock: a crescent for a part-filled wheel.
  const half = Math.max(0.04, fill) * Math.PI;
  const a0 = Math.PI - half;
  const a1 = Math.PI + half;
  const x0 = 15 + r * Math.cos(a0);
  const y0 = 15 - r * Math.sin(a0);
  const x1 = 15 + r * Math.cos(a1);
  const y1 = 15 - r * Math.sin(a1);
  const large = half > Math.PI / 2 ? 1 : 0;
  return (
    <svg viewBox="0 0 30 30" aria-hidden="true">
      <path
        d={`M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${large} 0 ${x1.toFixed(2)} ${y1.toFixed(2)}`}
        fill="none"
        stroke="currentColor"
        strokeWidth="7.5"
        strokeLinecap="round"
      />
    </svg>
  );
}

export function Icon({ icon }: { icon: CounterIcon }) {
  return (
    <svg viewBox="0 0 34 34" aria-hidden="true">
      {icon === 'orb' && (
        <>
          <circle cx="17" cy="17" r="16" fill="currentColor" />
          <g fill="var(--band)">
            <circle cx="17" cy="21" r="2.6" />
            <path d="M15.6 18.6 9 11.8l-.9 1.4 6 6.3ZM18.4 18.6l6.6-6.8.9 1.4-6 6.3Z" />
            <path d="M14.4 20 6.6 17.2l-.2 1.6 7.7 2.2ZM19.6 20l7.8-2.8.2 1.6-7.7 2.2Z" />
            <path d="M16.2 18.2 15.4 10h1.2l.4 7.8ZM17.8 18.2l.8-8.2h-1.2l-.4 7.8Z" />
          </g>
        </>
      )}
      {icon === 'shard' && (
        <>
          <path d="M17 0 34 17 17 34 0 17Z" fill="currentColor" />
          <path d="M17 7.6 26.4 17 17 26.4 7.6 17Z" fill="var(--band)" />
          <path
            d="M17 10.6c2.8 3.4 5.4 6.2 5.4 9.2 0 2.6-2.2 4.2-5.4 4.2s-5.4-1.6-5.4-4.2c0-3 2.6-5.8 5.4-9.2Z"
            fill="currentColor"
          />
        </>
      )}
      {icon === 'seed' && (
        <g transform="translate(17 17) scale(1.1) translate(-17 -17)">
          <path
            d="M6.5 8.2C11 7.4 16 9.4 19.6 13.6 22.4 12.8 26.6 13.4 29.6 16.6c3.8 4 2.4 11-4.4 12.2-4 .7-11.2.5-15.6-1.4C3.6 25 2.2 18.6 4.6 13.8c.7-1.5.9-3.6 1.9-5.6Z"
            fill="currentColor"
          />
          <path d="M9 13.6c3.2.2 6.6 1.8 8.8 4.4-3-1.3-6.2-1.9-9.4-1.6Z" fill="var(--band)" />
        </g>
      )}
      {icon === 'gem' && (
        <>
          <path d="M17 0 27 7.6v18.8L17 34 7 26.4V7.6Z" fill="currentColor" />
          <path d="M17 4.4 23.6 9.4v15.2L17 29.6l-6.6-5V9.4Z" fill="var(--band)" />
          <path d="M17 7.6 21 10.6v12.8L17 26.4l-4-3V10.6Z" fill="currentColor" />
        </>
      )}
    </svg>
  );
}

/* The creatures, in px of the screen: each drawn in its own box with its feet at the box's foot,
   then placed along the ground line by the viewBox below. */

function Rover() {
  return (
    <g>
      {/* A two-wheeled rover: a low frame, a seat, bars raised at the front. */}
      <path
        d="M7 12.4 15 7h7l3 2.6h7.4L39 4.4l3.2-.4-1.6 3.4 4 5.2-4.4 2.2H11.6Z"
        fill="currentColor"
      />
      <path d="M13.6 6.2 22.4 4.6l1.4 2.4H14.4Z" fill="currentColor" />
      <circle cx="9.4" cy="16.6" r="7.4" fill="currentColor" />
      <circle cx="38" cy="16.6" r="7.4" fill="currentColor" />
      <circle cx="9.4" cy="16.6" r="3" fill="var(--band)" />
      <circle cx="38" cy="16.6" r="3" fill="var(--band)" />
    </g>
  );
}

function Hawk() {
  return (
    <g>
      {/* The raised wing, behind, its primaries splayed at the tip. */}
      <path
        d="M44 60C39 34 46 14 58 0l1 14 8-9-1.4 16 8.4-6.4-5.6 20C65 46 59 54 54 62Z"
        fill="currentColor"
      />
      {/* The fanned tail at the foot. */}
      <path d="M36 88 17 119l10-4 2 4.6 7.6-9.6 3.4 5.2L47 94Z" fill="currentColor" />
      {/* The body, leaning into the climb, and the head with its hooked beak. */}
      <ellipse cx="49" cy="70" rx="12.5" ry="29" transform="rotate(32 49 70)" fill="currentColor" />
      <circle cx="65" cy="41" r="10" fill="currentColor" />
      <path d="M72 35.6 89 43l-11.6 1-2 5.4Z" fill="currentColor" />
      {/* The near wing, swept forward and down to the left. */}
      <path
        d="M45 66C33 58 16 55 0 58l11 4.4-9 5.4 13 .6-7 6.6 17.4-2.4C33 72 39 72 45 74Z"
        fill="currentColor"
      />
      <g fill="none" stroke="var(--band)" strokeWidth="2" strokeLinecap="round">
        <path d="M52.6 20.6C49.6 33 50 45 53 54M59.6 24C57 34 57 44 58.6 52" />
        <path d="M60 64C55 76 47 86 37 92" />
        <path d="M14 63.4 30 64M17 69.4 32 68.6" />
      </g>
      <circle cx="67.6" cy="39" r="2.4" fill="var(--band)" />
    </g>
  );
}

function Strider() {
  return (
    <g>
      {/* The crest stalk, curling up from the rump, with rattle beads and three bulbs. */}
      <path
        d="M24 42C12 36 6 24 8 8"
        fill="none"
        stroke="currentColor"
        strokeWidth="4.6"
        strokeLinecap="round"
      />
      <circle cx="8" cy="6" r="5.4" fill="currentColor" />
      <circle cx="2.4" cy="10.4" r="2.6" fill="currentColor" />
      <circle cx="14" cy="9.6" r="3" fill="currentColor" />
      <g fill="currentColor">
        <circle cx="5.4" cy="21" r="3" />
        <circle cx="7" cy="29" r="3" />
        <circle cx="10.8" cy="36" r="3" />
        <circle cx="16.4" cy="41" r="3" />
      </g>
      {/* Twin humps on a deep body; a long neck reaching forward to a low head. */}
      <path d="M26 36C27 16 44 14 47 27c3-15 22-14 23 4Z" fill="currentColor" />
      <ellipse cx="48" cy="44" rx="28" ry="17" fill="currentColor" />
      <path d="M66 36C74 34 80 28 82 20l8 2C88 36 82 46 70 52Z" fill="currentColor" />
      <path d="M78 16C80 10 88 9 92 14l1 6.4-6 1.6-9-1Z" fill="currentColor" />
      {/* Stilt legs with jointed knees. */}
      <g fill="currentColor">
        <path d="M31 54h7l-.5 15 1.5 2-1.5 2 .5 21h-7l.5-21-1.5-2 1.5-2Z" />
        <path d="M58 54h7l-.5 15 1.5 2-1.5 2 .5 21h-7l.5-21-1.5-2 1.5-2Z" />
      </g>
      <g fill="var(--band)">
        <circle cx="34.5" cy="71" r="1.5" />
        <circle cx="61.5" cy="71" r="1.5" />
        <circle cx="34.5" cy="82" r="1.3" />
        <circle cx="61.5" cy="82" r="1.3" />
        <circle cx="87" cy="14.6" r="1.7" />
      </g>
      <path
        d="M30 44c8 6 22 7 34 1"
        fill="none"
        stroke="var(--band)"
        strokeWidth="2.2"
        strokeLinecap="round"
      />
    </g>
  );
}

function Skink() {
  return (
    <g>
      {/* The tail, curling up and over to the right, ending in a fan. */}
      <path
        d="M84 60C102 60 110 44 110 20"
        fill="none"
        stroke="currentColor"
        strokeWidth="7"
        strokeLinecap="round"
      />
      <path d="M102 13 104 4l4 6 2-8 3 8 4-6 2 9-8 4Z" fill="currentColor" />
      {/* Splayed legs. */}
      <g stroke="currentColor" strokeWidth="5" strokeLinecap="round" fill="none">
        <path d="M36 52 28 70l-6 4" />
        <path d="M44 52 50 72l6 4" />
        <path d="M70 62 64 80l-8 4" />
        <path d="M76 58 90 74" />
      </g>
      {/* The body, low and long; the head with a frilled jaw. */}
      <path d="M20 44C34 34 54 38 70 46L86 56 84 66C70 66 52 58 36 56L22 54Z" fill="currentColor" />
      <path d="M0 40 12 32 26 36 28 48 16 52 6 48Z" fill="currentColor" />
      <path d="M24 30 32 20l2 12M30 34l10-8-1 10" fill="currentColor" />
      <g fill="var(--band)">
        <circle cx="12" cy="40" r="2" />
        <circle cx="42" cy="44" r="1.8" />
        <circle cx="52" cy="46" r="1.8" />
        <circle cx="62" cy="50" r="1.8" />
        <circle cx="72" cy="55" r="1.8" />
      </g>
      <path
        d="M30 50c12 1 26 4 40 10"
        fill="none"
        stroke="var(--band)"
        strokeWidth="1.8"
        strokeLinecap="round"
      />
    </g>
  );
}

function Tortoise() {
  return (
    <g>
      {/* The tusked head, low on the left. */}
      <circle cx="18" cy="40" r="13" fill="currentColor" />
      <path d="M8 44 0 42.4 4 48.4 12 50Z" fill="currentColor" />
      <path d="M2 34 12 36" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
      {/* The domed, plated shell and its rim. */}
      <path d="M22 54C20 26 38 4 64 4s44 22 42 50Z" fill="currentColor" />
      <path d="M18 52h92l-4 8H22Z" fill="currentColor" />
      {/* Stumpy legs. */}
      <g fill="currentColor">
        <rect x="28" y="56" width="18" height="23" rx="3" />
        <rect x="80" y="56" width="20" height="23" rx="3" />
      </g>
      <g fill="none" stroke="var(--band)" strokeWidth="2.2" strokeLinecap="round">
        <path d="M36 50C36 32 46 18 64 14M92 50C92 32 82 18 64 14" />
        <path d="M64 15v35M50 30h28" />
        <path d="M24 56h84" />
      </g>
      <circle cx="16" cy="38" r="2.4" fill="var(--band)" />
      <path d="M32 72h10M85 72h10" stroke="var(--band)" strokeWidth="1.8" />
    </g>
  );
}

/** Where each stands, in px of the screen, from the rover's left edge (x) and the ground (h). */
export const CREATURES = [
  { id: 'hawk', label: 'Hawk', x: 127, w: 89, h: 119, Art: Hawk },
  { id: 'strider', label: 'Strider', x: 241, w: 93, h: 94, Art: Strider },
  { id: 'skink', label: 'Skink', x: 353, w: 125, h: 87, Art: Skink },
  { id: 'tortoise', label: 'Tortoise', x: 482, w: 108, h: 79, Art: Tortoise },
] as const;
export const ROVER = { x: 0, w: 47, h: 24, Art: Rover };
/** The row's box: 590 px wide, 120 high, ground at its foot. */
export const ROW = { w: 590, h: 120 };
```

### loading-tip/use-autoplay.ts

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

'use client';

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

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
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 };
}
```