# Save slots

> A game’s “Select save data to load.” screen: slot plates with a painted thumbnail, date, place and Autosave tag, a bright sliding frame and a load question.

The screen between the title menu and play, where the player picks the save to resume. Each slot is a dark plate with a small painted picture of the moment, the date and time, the place in large italics and a grey Autosave tag when the game saved by itself. A bright cream frame slides to the slot under the cursor, and that slot’s picture fills the back of the screen, blurred. Choosing asks “Load this save data?” with two choice pills; loading fades to black with a spinner. Cancel (B) waits in the corner. Browse with the arrows, the wheel, a hover or a gamepad.

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

Category: Games · Screens.

## Anatomy

1. **Screen**: A 960 × 540 game screen (392 × 600 on a narrow stage) with its own dark surface; the framed slot’s picture, blown up and blurred, fills its back.
2. **Prompt**: The line above the column (`prompt`), centred and italic like the rest of the screen.
3. **Slot plate**: A near-black 360 × 62 plate on a 70-unit pitch: one `role="option"` per save. The framed one warms a shade.
4. **Thumbnail**: The moment of the save: a painted landscape 130 units wide on the plate’s left, cropped to cover it like a screenshot.
5. **Location**: Where the save was made, the slot’s big line, under the date and time (`savedAt`, shown as given).
6. **Autosave tag**: A grey slip with a slanted left side, flush with the plate’s bottom right, on saves with `autosave`.
7. **Frame**: The cursor: a bright frame with filled corners that slides to the selected slot with a small overshoot and breathes a soft glow.
8. **Cancel (B)**: A real button in the corner: the word and a round B glyph, which dips when B is pressed. From the list it calls `onCancel`.

## When to use

- The load screen between a game’s title menu and play, where the player picks which save to resume.
- Saves that are told apart by a moment, not a name: a picture of where they were, the place and the date.
- A mix of manual saves and saves the game made on its own, which the player should tell apart at a glance (the Autosave tag).
- A screen driven by a controller or the keyboard first, with a cursor that moves one slot at a time and an A / B pair to choose and back out.
- Loading something that throws away the current play, so it is worth one “Load this save data?” before it happens.

## When not to use

- Picking a file or document in an app. Use a file list or an open dialog with names, sizes and dates.
- A single continue button with one save behind it. Use a title menu item such as Continue.
- Choosing between a few short options with no picture or date. Use a choice list or a pause menu.
- Saving over a slot, where the question is about losing data. Give it its own overwrite flow with a stronger warning, not this load question.
- A page that should follow the reader’s light or dark theme: this is a dark game screen on any page. Use a plain list or card grid.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/save-slots.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 { SaveSlots, type SaveSlot } from './save-slots';

const saves: SaveSlot[] = [
  { id: 'a', savedAt: '05/10/2026 16:05', location: 'Amber Highlands', autosave: true, scene: 'dunes' },
  { id: 'b', savedAt: '24/09/2026 13:25', location: 'Stoneback Pass', scene: 'arch' },
];

export function LoadScreen({ onBack }: { onBack: () => void }) {
  return (
    <SaveSlots
      slots={saves}
      autoplay={false}
      onLoad={(slot) => console.log('load', slot.id)}
      onCancel={onBack}
    />
  );
}
```

## States

- **List**: At rest on the first slot: the column starts at the top and the faded foot shows there is more below.
- **Browsing**: `defaultSelected={3}`: the frame sits on the fourth save and the backdrop is its picture.
- **Scrolled**: `defaultSelected={6}`, the last save: the column has scrolled so the frame keeps a row of context above it.
- **Load question**: `defaultPhase="confirm"`: “Load this save data?” with the slot’s place and date, Load chosen and Cancel (B) beside it. The corner hint dims.
- **Loading**: `defaultPhase="loading"`: the screen fades to black with a small spinner, held here as a still.
- **Few saves**: Three manual saves and no Autosave tags: the column simply ends, with nothing to scroll.
- **Narrow**: On a stage under 600 px the screen becomes 392 × 600: the same slots, a taller column, the hint closer in.
- **Light page**: `colorScheme="light"`: no edge and a soft drop shadow. The screen itself keeps its dark colours.
- **Dark page**: `colorScheme="dark"`: a faint light edge and a deeper shadow so the screen stands off a dark page.

## How it works

- The screen is one drawing in design units: --u comes from the stage’s width by a container query unit (min(1px, 100cqi / 960)), and every size is a multiple of it, so the 960 × 540 screen scales as a whole and keeps the kit’s proportions: 360 × 62 slot plates on a 70-unit pitch, a 130-unit thumbnail, 10-unit date and 14-unit place. On a stage under 600 px the screen becomes a 392 × 600 portrait one, with the same plates.
- The thumbnails are original painted landscapes, one SVG per scene (dunes, a rock arch, docks, a cliff, a basin, a snowfield, a night wood), layered shapes and gradients with a small traveller in each, cropped to cover the plate as a screenshot would. The selected one is drawn again behind everything, enlarged, blurred and half transparent under a night-blue shade; moving the cursor crossfades it to the next one.
- The frame is a single element in the column, moved by translate to the selected row with an overshooting curve (190 ms), so it slides rather than jumps. It is a cream line inside a dark olive one, with a faint grey halo, a soft glow that breathes, and four small cream corner wedges drawn as corner gradients. At either end of the list the frame only nudges.
- The column scrolls by translate too, only when the cursor nears its edges, so a row of context stays visible below it; its foot fades out by a mask, as the kit’s last slot does, and its top fades once it has scrolled.
- Choosing flashes the framed plate and opens the question: the screen dims, “Load this save data?” and the slot’s place and date rise in, with Load and Cancel (B) choice pills, the chosen one framed in cream with a pointer. Loading fades the screen to black over 420 ms with a dot spinner while the backdrop drifts forward, then the list comes back and its plates rise in again one by one, 45 ms apart.
- Input: the arrows or W and S move, A, Enter or Space choose, B, Escape or Backspace go back, while focus is inside; the wheel steps the cursor over the list (the page scrolls on past either end); a mouse hover moves the cursor and a click loads; on touch, a first tap selects and a second chooses; a connected gamepad’s d-pad, stick, A and B work too.
- Uncontrolled, a demo browses down and back, opens the question, backs out with B, and loads a save, while the screen is on view and untouched; any real input hands it to the reader. Given selected and onSelect, the cursor is the game’s.

## API reference

- **slots** (`SaveSlot[]`, default `seven sample saves`): The saves: { id, location, savedAt, autosave?, scene? }. savedAt is shown as given; scene picks the painted thumbnail (dunes, arch, docks, cliff, basin, snow, forest).
- **selected** (`number`): The slot under the cursor, controlled, as an index into slots. Turns the demo off.
- **defaultSelected** (`number`, default `0`): Uncontrolled, the slot the cursor starts on.
- **defaultPhase** (`'list' | 'confirm' | 'loading'`, default `'list'`): Opens on the list, on the load question, or on the loading screen, which then holds as a still. Set, it turns the demo off.
- **onSelect** (`(index: number) => void`): Called when the cursor moves to another slot.
- **onLoad** (`(slot: SaveSlot, index: number) => void`): Called when a save is loaded, after the question when confirm is on.
- **onCancel** (`() => void`): Called on Cancel (B), from the list.
- **prompt** (`string`, default `'Select save data to load.'`): The line above the list.
- **confirm** (`boolean`, default `true`): Ask “Load this save data?” before loading.
- **keys** (`boolean`, default `true`): Listen to the keyboard while focus is inside, and to a connected gamepad.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short demo: browsing, asking, backing out and loading. Any input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The screen keeps its own dark colours; its edge, shadow and focus ring follow the scheme. Set --slots-unit on the root to fix the size (1px is the 960 × 540 screen).
- **className** (`string`): Added to the root.

## Accessibility

- The slots are a listbox named by the prompt, with one option per slot (“Amber Highlands, 05/10/2026 16:05, autosave”); the list keeps focus and aria-activedescendant follows the cursor, so screen readers read each slot as it is reached.
- The question is an alertdialog named “Load this save data?”; focus moves to its Load pill when it opens and back to the list when it closes. Cancel (B) is a real button.
- A polite live region says when the question opens, when a save is loading and on cancel.
- Focus shows as a white outline round the frame and a ring round the screen, and as an outline on the pills and the Cancel button.
- Under reduced motion the demo does not play and every change is instant: no sliding frame, rising rows, breathing glow or drifting backdrop. The pictures are decorative and hidden from assistive technology.

## Source

### save-slots/save-slots.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { SceneArt, type Scene } from './scenes';
import { useAutoplay } from './use-autoplay';
import styles from './save-slots.module.css';

/*
 * Save slots: the screen between a game's title menu and play, where the player picks the save to
 * resume. "Select save data to load." over a column of slots, each a dark plate with a picture of
 * the moment (a small painted landscape, scenes.tsx), the date and time, the place, and an
 * Autosave tag when the game saved by itself. A bright frame marks the slot under the cursor, and
 * that slot's picture, blown up and blurred, fills the back of the screen. Cancel (B) sits in the
 * corner.
 *
 * - Moving the cursor (arrows or W/S, a gamepad's d-pad or stick, the mouse wheel, hovering) slides
 *   the frame to the next slot with a small overshoot, warms that plate, and crossfades the
 *   backdrop. The column scrolls when the frame nears its faded foot, so there is always a slot of
 *   context below; at either end the frame only nudges.
 * - Choosing (A, Enter, Space, a click on the framed slot, a second tap on touch) flashes the slot
 *   and asks "Load this save data?" with two choice pills, Load and Cancel (B); with
 *   `confirm={false}` it loads at once. Loading fades the screen to black with a spinner, calls
 *   `onLoad`, and (here) comes back to the list, which rises in again slot by slot.
 * - B, Escape or Backspace close the question, or call `onCancel` from the list; the B glyph dips.
 * - Uncontrolled, a short demo browses, opens the question, backs out, and loads a save, while it
 *   is on screen and untouched; any real input hands it to the reader. `selected` and `onSelect`
 *   control the cursor from a game.
 * - The screen is a whole game screen, so it draws its own dark surface, the same on a light or a
 *   dark page; only the focus ring and the screen's edge follow the scheme. Its sizes are design
 *   units (1 px at full size) of a 960 × 540 screen, or a 392 × 600 one on a narrow stage, scaled
 *   to fit; --slots-unit fixes the unit.
 */

export type SaveSlot = {
  /** A stable key for the slot. */
  id: string;
  /** Where the save was made, the slot's big line. */
  location: string;
  /** When, shown as given (e.g. '05/10/2026 16:05'). */
  savedAt: string;
  /** Saved by the game on its own: shows the Autosave tag. */
  autosave?: boolean;
  /** The slot's picture. */
  scene?: Scene;
};

const DEFAULT_SLOTS: SaveSlot[] = [
  {
    id: 's1',
    savedAt: '05/10/2026 16:05',
    location: 'Amber Highlands',
    autosave: true,
    scene: 'dunes',
  },
  { id: 's2', savedAt: '24/09/2026 13:25', location: 'Stoneback Pass', scene: 'arch' },
  {
    id: 's3',
    savedAt: '24/09/2026 17:05',
    location: 'Old Weir Docks',
    autosave: true,
    scene: 'docks',
  },
  {
    id: 's4',
    savedAt: '24/09/2026 12:05',
    location: 'Ochre Cliffs',
    autosave: true,
    scene: 'cliff',
  },
  { id: 's5', savedAt: '23/09/2026 16:05', location: 'Dry Basin', autosave: true, scene: 'basin' },
  { id: 's6', savedAt: '22/09/2026 03:05', location: 'Frostmere Steppe', scene: 'snow' },
  {
    id: 's7',
    savedAt: '20/09/2026 21:40',
    location: 'Lantern Woods',
    autosave: true,
    scene: 'forest',
  },
];
const FALLBACK_SCENES: Scene[] = ['dunes', 'arch', 'docks', 'cliff', 'basin', 'snow', 'forest'];

type Phase = 'list' | 'confirm' | 'loading';
type Action = 'up' | 'down' | 'confirm' | 'cancel';

/** How long the black loading screen stays before the list comes back, in ms. */
const LOADING = 1700;
/** How long a demo or key press keeps the B glyph down, in ms. */
const TAP = 150;
/** The demo: actions at ms from the start of each run, and the run's length. */
const DEMO: [number, Action | 'reset'][] = [
  [0, 'reset'],
  [1300, 'down'],
  [2000, 'down'],
  [2700, 'down'],
  [3400, 'down'],
  [4100, 'down'],
  [5000, 'up'],
  [5700, 'up'],
  [6600, 'confirm'],
  [7700, 'cancel'],
  [8500, 'up'],
  [9300, 'confirm'],
  [10100, 'down'],
  [10700, 'up'],
  [11300, 'confirm'],
];
const DEMO_LENGTH = 14600;

/** A standard-layout gamepad's buttons: right face (A), bottom face (B), d-pad up and down. */
const PAD: [number, Action][] = [
  [1, 'confirm'],
  [0, 'cancel'],
  [12, 'up'],
  [13, 'down'],
];

function keyAction(event: ReactKeyboardEvent): Action | null {
  if (event.metaKey || event.ctrlKey || event.altKey) return null;
  switch (event.key) {
    case 'ArrowUp':
    case 'w':
    case 'W':
      return 'up';
    case 'ArrowDown':
    case 's':
    case 'S':
      return 'down';
    case 'Enter':
    case ' ':
    case 'a':
    case 'A':
      return 'confirm';
    case 'Escape':
    case 'Backspace':
    case 'b':
    case 'B':
      return 'cancel';
  }
  return null;
}

/** The first row on screen, so the cursor keeps a row of context below it (and above it). */
function scrollFor(selected: number, top: number, count: number) {
  const last = Math.max(0, count - 5);
  if (selected >= top + 4) return Math.min(selected - 3, last);
  if (selected <= top) return Math.max(selected - 1, 0);
  return Math.min(top, last);
}

export function SaveSlots({
  slots = DEFAULT_SLOTS,
  selected,
  defaultSelected = 0,
  defaultPhase,
  onSelect,
  onLoad,
  onCancel,
  prompt = 'Select save data to load.',
  confirm = true,
  keys = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The saves, newest first or in any order the game keeps them. */
  slots?: SaveSlot[];
  /** The slot under the cursor, controlled (an index into `slots`). */
  selected?: number;
  /** Uncontrolled, the slot the cursor starts on. */
  defaultSelected?: number;
  /**
   * Opens on the list, on the load question, or on the black loading screen (which then holds, as
   * a still). Set, it also turns the demo off.
   */
  defaultPhase?: 'list' | 'confirm' | 'loading';
  /** Called when the cursor moves to another slot. */
  onSelect?: (index: number) => void;
  /** Called when a save is loaded (after the question, if `confirm`). */
  onLoad?: (slot: SaveSlot, index: number) => void;
  /** Called on Cancel (B) from the list. */
  onCancel?: () => void;
  /** The line above the list. */
  prompt?: string;
  /** Ask "Load this save data?" before loading. */
  confirm?: boolean;
  /** Listen to the keyboard (while focus is inside) and to a connected gamepad. */
  keys?: boolean;
  /** While on screen and untouched, a short demo: browsing, asking, backing out, loading. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(7000);
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, '');
  const listRef = useRef<HTMLUListElement>(null);
  const pillRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const controlled = selected !== undefined;
  const demo = autoplay && playing && !controlled && defaultPhase === undefined;

  const count = slots.length;
  const clamp = (index: number) => Math.min(Math.max(index, 0), Math.max(count - 1, 0));
  const [own, setOwn] = useState(() => clamp(defaultSelected));
  const current = clamp(selected ?? own);
  const slot = slots[current];

  const [phase, setPhase] = useState<Phase>(defaultPhase ?? 'list');
  const [choice, setChoice] = useState(0);
  const [round, setRound] = useState(0);
  const [bump, setBump] = useState<{ n: number; dir: 'up' | 'down' }>({ n: 0, dir: 'up' });
  const [flash, setFlash] = useState(0);
  const [bHeld, setBHeld] = useState(false);
  const [said, setSaid] = useState('');

  // The column's scroll follows the cursor, adjusted while rendering (no extra frame).
  // A pointer pick only scrolls when the slot is not fully on screen, so the list never slides
  // away under a hovering mouse.
  const [top, setTop] = useState(() => scrollFor(current, 0, count));
  const [seen, setSeen] = useState(current);
  const [byPointer, setByPointer] = useState(false);
  if (seen !== current) {
    setSeen(current);
    const shown = current >= top && current <= top + 4;
    if (!(byPointer && shown)) setTop(scrollFor(current, top, count));
  }

  // Timers that die with the component (and with the demo, when it stops).
  const timers = useRef(new Set<number>());
  const later = (ms: number, run: () => void) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      run();
    }, ms);
    timers.current.add(timer);
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

  const select = (index: number, pointer = false, fromDemo = false) => {
    const next = clamp(index);
    if (next === current) return;
    setByPointer(pointer);
    if (!controlled) setOwn(next);
    if (!fromDemo) onSelect?.(next);
  };

  const pressB = () => {
    setBHeld(true);
    later(TAP, () => setBHeld(false));
  };

  const load = (fromDemo: boolean) => {
    if (!slot) return;
    setPhase('loading');
    setSaid(`Loading ${slot.location}.`);
    if (!fromDemo) onLoad?.(slot, current);
    later(LOADING, () => {
      setPhase('list');
      setRound((n) => n + 1);
      setSaid('');
    });
  };

  const act = useEffectEvent((action: Action, fromDemo = false) => {
    if (phase === 'loading' || !slot) return;
    if (phase === 'confirm') {
      if (action === 'up' || action === 'down') setChoice(action === 'up' ? 0 : 1);
      else if (action === 'cancel') {
        pressB();
        setPhase('list');
        setSaid('');
      } else if (choice === 0) load(fromDemo);
      else {
        pressB();
        setPhase('list');
        setSaid('');
      }
      return;
    }
    if (action === 'up' || action === 'down') {
      const next = current + (action === 'up' ? -1 : 1);
      if (next < 0 || next >= count) setBump((b) => ({ n: b.n + 1, dir: action }));
      else select(next, false, fromDemo);
    } else if (action === 'confirm') {
      setFlash((n) => n + 1);
      if (confirm) {
        setChoice(0);
        setPhase('confirm');
        setSaid(`Load this save data? ${slot.location}, ${slot.savedAt}.`);
      } else load(fromDemo);
    } else {
      pressB();
      setSaid('Cancel.');
      later(1500, () => setSaid((now) => (now === 'Cancel.' ? '' : now)));
      if (!fromDemo) onCancel?.();
    }
  });

  // The demo, again and again while it may play.
  useEffect(() => {
    if (!demo) return;
    const run = () => {
      for (const [at, step] of DEMO) {
        later(at, () => {
          if (step !== 'reset') return act(step, true);
          setOwn(0);
          setPhase('list');
          setSaid('');
        });
      }
    };
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    const all = timers.current;
    return () => {
      window.clearInterval(loop);
      all.forEach((timer) => window.clearTimeout(timer));
      all.clear();
      setBHeld(false);
      // A demo cut short mid-load still comes back to the list.
      setPhase((now) => (now === 'loading' ? 'list' : now));
    };
  }, [demo]);

  // Focus follows the screen: the question's pill while it is open, the list when it closes.
  const lastPhase = useRef(phase);
  useEffect(() => {
    const was = lastPhase.current;
    lastPhase.current = phase;
    const element = root.current;
    if (!element || !element.contains(document.activeElement)) return;
    if (phase === 'confirm') pillRefs.current[choice]?.focus({ preventScroll: true });
    else if (was !== 'list') listRef.current?.focus({ preventScroll: true });
  }, [phase, choice, root]);

  // Keys, while focus is inside.
  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (!keys) return;
    const action = keyAction(event);
    if (!action) return;
    const target = event.target as HTMLElement;
    // Enter and Space on a pill or the Cancel hint are that button's own click.
    if ((event.key === 'Enter' || event.key === ' ') && target.tagName === 'BUTTON') {
      take();
      return;
    }
    event.preventDefault();
    take();
    if (!event.repeat || action === 'up' || action === 'down') act(action);
  };

  // The mouse wheel steps the cursor; the page scrolls on past either end.
  const viewport = useRef<HTMLDivElement>(null);
  const wheel = useEffectEvent((event: WheelEvent) => {
    if (phase !== 'list') return;
    const dir = event.deltaY > 0 ? 1 : -1;
    const next = current + dir;
    if (next < 0 || next >= count) return;
    event.preventDefault();
    take();
    const now = performance.now();
    wheelAt.current.sum += event.deltaY;
    if (Math.abs(wheelAt.current.sum) < 40 && now - wheelAt.current.at < 400) return;
    if (now - wheelAt.current.at < 90) return;
    wheelAt.current = { at: now, sum: 0 };
    select(next);
  });
  const wheelAt = useRef({ at: 0, sum: 0 });
  useEffect(() => {
    const element = viewport.current;
    if (!element) return;
    const listener = (event: WheelEvent) => wheel(event);
    element.addEventListener('wheel', listener, { passive: false });
    return () => element.removeEventListener('wheel', listener);
  }, []);

  // A gamepad: polled once one is connected (browsers only report one after a user gesture).
  const [pad, setPad] = useState(false);
  useEffect(() => {
    if (!keys || !('getGamepads' in navigator)) return;
    const connected = () => setPad(navigator.getGamepads().some(Boolean));
    connected();
    window.addEventListener('gamepadconnected', connected);
    window.addEventListener('gamepaddisconnected', connected);
    return () => {
      window.removeEventListener('gamepadconnected', connected);
      window.removeEventListener('gamepaddisconnected', connected);
    };
  }, [keys]);
  const held = useRef(new Set<string>());
  const poll = useEffectEvent(() => {
    const now = new Set<string>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      for (const [index, action] of PAD) if (gamepad.buttons[index]?.pressed) now.add(action);
      const y = gamepad.axes[1] ?? 0;
      if (y < -0.5) now.add('up');
      if (y > 0.5) now.add('down');
    });
    for (const action of now) {
      if (held.current.has(action)) continue;
      take();
      act(action as Action);
    }
    held.current = now;
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick() {
      poll();
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  const mouseAt = useRef('');
  const optionId = (index: number) => `${uid}-slot-${index}`;
  const sceneOf = (s: SaveSlot, index: number) =>
    s.scene ?? FALLBACK_SCENES[index % FALLBACK_SCENES.length];

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      data-paused={visible ? undefined : ''}
      onKeyDown={onKeyDown}
    >
      <div
        className={styles.screen}
        data-part="screen"
        data-phase={phase}
        role="group"
        aria-label="Load save data"
        aria-keyshortcuts={keys ? 'ArrowUp ArrowDown A B Enter Escape' : undefined}
      >
        <div className={styles.backdrop} data-part="backdrop" aria-hidden="true">
          {slots.map((s, index) => (
            <div
              key={s.id}
              className={styles.backdropArt}
              data-on={index === current ? '' : undefined}
            >
              {Math.abs(index - current) <= 1 && (
                <SceneArt
                  scene={sceneOf(s, index)}
                  id={`${uid}-bg-${index}`}
                  className={styles.art}
                />
              )}
            </div>
          ))}
          <div className={styles.shade} />
        </div>

        <p className={styles.prompt} data-part="prompt" id={`${uid}-prompt`}>
          {prompt}
        </p>

        <div
          ref={viewport}
          className={styles.viewport}
          data-part="viewport"
          data-scrolled={top > 0 || undefined}
        >
          <ul
            ref={listRef}
            className={styles.list}
            data-part="list"
            role="listbox"
            tabIndex={0}
            aria-labelledby={`${uid}-prompt`}
            aria-activedescendant={slot ? optionId(current) : undefined}
            style={{ '--top': top } as CSSProperties}
          >
            {slots.map((s, index) => (
              <li
                key={`${s.id}-${round}`}
                id={optionId(index)}
                className={styles.slot}
                data-part="slot"
                data-on={index === current ? '' : undefined}
                role="option"
                aria-selected={index === current}
                aria-label={`${s.location}, ${s.savedAt}${s.autosave ? ', autosave' : ''}`}
                style={{ '--i': index, '--rise': Math.max(0, index - top) } as CSSProperties}
                onPointerMove={(event) => {
                  // Only a mouse that really moved: not the list sliding under a still one.
                  const at = `${event.clientX},${event.clientY}`;
                  if (event.pointerType !== 'mouse' || phase !== 'list' || at === mouseAt.current)
                    return;
                  mouseAt.current = at;
                  if (index === current) return;
                  take();
                  select(index, true);
                }}
                onClick={() => {
                  take();
                  if (phase !== 'list') return;
                  if (index === current) act('confirm');
                  else select(index);
                }}
              >
                <span className={styles.thumb} data-part="thumb" aria-hidden="true">
                  <SceneArt
                    scene={sceneOf(s, index)}
                    id={`${uid}-th-${index}`}
                    className={styles.art}
                  />
                </span>
                <span className={styles.date} data-part="date">
                  {s.savedAt}
                </span>
                <span className={styles.place} data-part="location">
                  {s.location}
                </span>
                {s.autosave && (
                  <span className={styles.tag} data-part="autosave-tag">
                    Autosave
                  </span>
                )}
              </li>
            ))}
            {slot && (
              <li
                key={`frame-${round}`}
                className={styles.frame}
                data-part="frame"
                aria-hidden="true"
                role="presentation"
                style={{ '--at': current } as CSSProperties}
              >
                <span
                  key={bump.n}
                  className={styles.nudge}
                  data-dir={bump.n ? bump.dir : undefined}
                >
                  <span className={styles.frameLine} />
                  {flash > 0 && <span key={flash} className={styles.flash} />}
                </span>
              </li>
            )}
          </ul>
        </div>

        <button
          type="button"
          className={styles.cancel}
          data-part="cancel-hint"
          data-held={bHeld || undefined}
          aria-keyshortcuts={keys ? 'B Escape' : undefined}
          tabIndex={phase === 'list' ? 0 : -1}
          onClick={() => {
            take();
            act('cancel');
          }}
        >
          Cancel
          <span className={styles.glyph} data-part="b-glyph" aria-hidden="true">
            B
          </span>
        </button>

        <div
          className={styles.ask}
          data-part="confirm"
          data-on={phase === 'confirm' ? '' : undefined}
          aria-hidden={phase === 'confirm' ? undefined : true}
        >
          <div
            className={styles.askPlate}
            role="alertdialog"
            aria-modal="false"
            aria-labelledby={`${uid}-ask`}
          >
            <p className={styles.askTitle} id={`${uid}-ask`}>
              Load this save data?
            </p>
            <p className={styles.askSlot}>
              {slot?.location} <span>{slot?.savedAt}</span>
            </p>
            <div className={styles.pills}>
              {['Load', 'Cancel'].map((label, index) => (
                <button
                  key={label}
                  ref={(element) => {
                    pillRefs.current[index] = element;
                  }}
                  type="button"
                  className={styles.pill}
                  data-part="choice"
                  data-on={choice === index ? '' : undefined}
                  data-held={index === 1 && bHeld ? '' : undefined}
                  tabIndex={phase === 'confirm' ? 0 : -1}
                  onPointerEnter={(event) => {
                    if (event.pointerType === 'mouse') setChoice(index);
                  }}
                  onFocus={() => setChoice(index)}
                  onClick={() => {
                    take();
                    if (phase !== 'confirm') return;
                    if (index === 0) load(false);
                    else act('cancel');
                  }}
                >
                  <span className={styles.pointer} aria-hidden="true" />
                  {label}
                  {index === 1 && (
                    <span className={styles.pillGlyph} aria-hidden="true">
                      B
                    </span>
                  )}
                </button>
              ))}
            </div>
          </div>
        </div>

        <div
          className={styles.loading}
          data-part="loading"
          data-on={phase === 'loading' ? '' : undefined}
          aria-hidden="true"
        >
          <span className={styles.spinner} data-part="spinner">
            {[0, 1, 2, 3, 4, 5].map((dot) => (
              <i key={dot} style={{ '--d': dot } as CSSProperties} />
            ))}
          </span>
        </div>
      </div>
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}
```

### save-slots/save-slots.module.css

```css
/* Save slots (save-slots.tsx): a whole game screen, so it draws its own dark surface: the
   selected slot's picture blurred behind, the prompt, a column of slot plates with a bright frame
   on one, Cancel (B) in the corner, and the load question and loading screen over it.

   Every size is a multiple of --u, one design unit (1 px at full size) of a 960 × 540 screen
   (--sw × --sh), or 392 × 600 on a narrow stage. --u comes from the stage's width by a container
   query unit, so the screen scales as one drawing; --slots-unit fixes it. Slots are 360 × 62 on a
   70-unit pitch, as the kit draws them at this scale.

   Colours are the tokens below. The screen keeps its colours on any page, as a game screen does;
   only the focus ring, the screen's edge and its shadow follow the scheme, through light-dark().

   Motion: the frame slides by translate with an overshoot; the column scrolls by translate; the
   backdrop crossfades; the rows rise in by keyframes, staggered by --rise; the question and the
   loading screen fade and scale in. All of it stops under reduced motion, and the frame's breath
   pauses off screen. */

.root {
  /* The screen. */
  --night: #030b14;
  --ink: #ebe7db;
  --ink-dim: #d9d5c8;
  --plate: #020305;
  --plate-on: #121210;
  --plate-line: #17181a;
  --tag: #52514b;
  --tag-ink: #e4e0d4;
  --frame: #dcd8c3;
  --frame-in: #4f4b34;
  --frame-glow: rgb(232 226 196 / 0.5);
  --glyph: #ebe7db;
  --glyph-ink: #0b0b0a;
  --pill: #0d0d0c;
  --pill-line: #34322d;
  /* On the page: focus, the screen's edge and its shadow. */
  --focus: #1b1a18;
  --edge: rgb(0 0 0 / 0);
  --drop: rgb(10 14 22 / 0.22);
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  align-content: center;
  box-sizing: border-box;
  width: 100%;
  padding: 24px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --focus: light-dark(#1b1a18, #fff);
    --edge: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.1));
    --drop: light-dark(rgb(10 14 22 / 0.22), 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;
}

/* ---- The screen ---- */

.screen {
  --sw: 960;
  --sh: 540;
  --list-top: 72;
  --list-h: 412;
  --prompt-top: 44;
  --hint-right: 50;
  --hint-bottom: 20;
  --u: var(--slots-unit, min(1px, calc(100cqi / var(--sw))));
  position: relative;
  width: calc(var(--sw) * var(--u));
  height: calc(var(--sh) * var(--u));
  overflow: hidden;
  border-radius: calc(3 * var(--u));
  background: var(--night);
  box-shadow:
    0 0 0 1px var(--edge),
    0 calc(14 * var(--u)) calc(40 * var(--u)) var(--drop);
  color: var(--ink);
  font-style: italic;
  line-height: 1;
  user-select: none;
  -webkit-user-select: none;
}
@container (max-width: 599px) {
  .screen {
    --sw: 392;
    --sh: 600;
    --list-top: 56;
    --list-h: 486;
    --prompt-top: 26;
    --hint-right: 16;
    --hint-bottom: 18;
  }
}

/* The selected slot's picture, blown up and blurred, under a night shade. */
.backdrop {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.backdropArt {
  position: absolute;
  inset: calc(-40 * var(--u));
  opacity: 0;
  filter: blur(calc(26 * var(--u))) saturate(0.85);
  transition: opacity 420ms ease;
}
.backdropArt[data-on] {
  opacity: 0.42;
}
.screen[data-phase='loading'] .backdropArt[data-on] {
  scale: 1.08;
  transition:
    opacity 420ms ease,
    scale 1600ms cubic-bezier(0.2, 0.6, 0.3, 1);
}
.art {
  display: block;
  width: 100%;
  height: 100%;
}
.shade {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(
      ellipse 34% 60% at 50% 46%,
      rgb(3 11 20 / 0.82),
      rgb(3 11 20 / 0.55) 70%,
      rgb(3 11 20 / 0)
    ),
    linear-gradient(180deg, rgb(3 11 20 / 0.6), rgb(3 11 20 / 0.25) 30%, rgb(3 11 20 / 0.75)),
    linear-gradient(rgb(3 14 28 / 0.4), rgb(3 14 28 / 0.4));
}

.prompt {
  position: absolute;
  inset: calc(var(--prompt-top) * var(--u)) 0 auto;
  margin: 0;
  font-size: calc(14 * var(--u));
  font-weight: 500;
  text-align: center;
  letter-spacing: 0.01em;
  text-shadow: 0 0 calc(6 * var(--u)) rgb(0 0 0 / 0.6);
  animation: fade 360ms ease both;
}

/* ---- The column ---- */

/* Wider and taller than the plates, so the frame's glow is not clipped; the foot fades out. */
.viewport {
  position: absolute;
  left: calc((var(--sw) - 380) / 2 * var(--u));
  top: calc(var(--list-top) * var(--u));
  width: calc(380 * var(--u));
  height: calc(var(--list-h) * var(--u));
  overflow: hidden;
  mask-image: linear-gradient(
    180deg,
    #000 0,
    #000 calc(100% - 54 * var(--u)),
    rgb(0 0 0 / 0.45) calc(100% - 24 * var(--u)),
    transparent
  );
}
.viewport[data-scrolled] {
  mask-image: linear-gradient(
    180deg,
    transparent,
    #000 calc(6 * var(--u)),
    #000 calc(100% - 54 * var(--u)),
    rgb(0 0 0 / 0.45) calc(100% - 24 * var(--u)),
    transparent
  );
}
.list {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
  outline: none;
  translate: 0 calc(var(--top) * -70 * var(--u));
  transition: translate 260ms cubic-bezier(0.25, 0.9, 0.3, 1);
}
.slot {
  position: absolute;
  left: calc(10 * var(--u));
  top: calc((6 + var(--i) * 70) * var(--u));
  box-sizing: border-box;
  width: calc(360 * var(--u));
  height: calc(62 * var(--u));
  border: calc(1 * var(--u)) solid var(--plate-line);
  border-radius: calc(2 * var(--u));
  background: var(--plate);
  cursor: pointer;
  animation: rise 420ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
  animation-delay: calc(var(--rise) * 45ms + 80ms);
  transition: background-color 160ms ease;
}
.slot[data-on] {
  background: var(--plate-on);
}
.thumb {
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(130 * var(--u));
  overflow: hidden;
  border-radius: calc(1 * var(--u)) 0 0 calc(1 * var(--u));
}
.date,
.place {
  position: absolute;
  left: calc(137 * var(--u));
  right: calc(8 * var(--u));
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.date {
  top: calc(8 * var(--u));
  color: var(--ink-dim);
  font-size: calc(10 * var(--u));
  letter-spacing: 0.02em;
}
.place {
  top: calc(22 * var(--u));
  padding-block: calc(1 * var(--u));
  font-size: calc(14 * var(--u));
  font-weight: 450;
}
/* The tag: a flat grey slip with a slanted left side, flush with the plate's bottom right. */
.tag {
  position: absolute;
  right: 0;
  bottom: 0;
  display: grid;
  place-items: center;
  width: calc(68 * var(--u));
  height: calc(17 * var(--u));
  padding-left: calc(4 * var(--u));
  box-sizing: border-box;
  background: var(--tag);
  color: var(--tag-ink);
  font-size: calc(10 * var(--u));
  clip-path: polygon(calc(5 * var(--u)) 0, 100% 0, 100% 100%, 0 100%);
}

/* The frame: a cream line inside an olive one, a faint grey halo and a glow, with small cream
   corner wedges, slid to the selected plate. */
.frame {
  position: absolute;
  left: calc(10 * var(--u));
  top: calc(6 * var(--u));
  width: calc(360 * var(--u));
  height: calc(62 * var(--u));
  translate: 0 calc(var(--at) * 70 * var(--u));
  transition: translate 190ms cubic-bezier(0.3, 1.45, 0.5, 1);
  pointer-events: none;
  animation: fade 300ms 260ms ease both;
}
.nudge {
  position: absolute;
  inset: 0;
}
.nudge[data-dir='up'] {
  animation: nudge-up 220ms ease-out;
}
.nudge[data-dir='down'] {
  animation: nudge-down 220ms ease-out;
}
.frameLine {
  position: absolute;
  inset: calc(-1.5 * var(--u));
  border: calc(1.6 * var(--u)) solid var(--frame);
  border-radius: calc(3 * var(--u));
  background:
    linear-gradient(135deg, var(--frame) calc(4.6 * var(--u)), transparent 0) 0 0 /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(225deg, var(--frame) calc(4.6 * var(--u)), transparent 0) 100% 0 /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(45deg, var(--frame) calc(4.6 * var(--u)), transparent 0) 0 100% /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(315deg, var(--frame) calc(4.6 * var(--u)), transparent 0) 100% 100% /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat;
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) var(--frame-in),
    0 0 0 calc(1 * var(--u)) rgb(120 126 122 / 0.55),
    0 0 calc(7 * var(--u)) calc(1 * var(--u)) var(--frame-glow);
}
.frameLine::after {
  content: '';
  position: absolute;
  inset: calc(-1.6 * var(--u));
  border-radius: inherit;
  box-shadow: 0 0 calc(12 * var(--u)) calc(2 * var(--u)) var(--frame-glow);
  opacity: 0;
  animation: breathe 1800ms ease-in-out infinite alternate;
}
.flash {
  position: absolute;
  inset: calc(-1.5 * var(--u));
  border-radius: calc(3 * var(--u));
  background: rgb(255 250 228 / 0.28);
  box-shadow: 0 0 calc(18 * var(--u)) calc(4 * var(--u)) rgb(255 248 220 / 0.6);
  opacity: 0;
  animation: flash 420ms ease-out;
}

/* ---- Cancel (B) ---- */

.cancel {
  position: absolute;
  right: calc(var(--hint-right) * var(--u));
  bottom: calc(var(--hint-bottom) * var(--u));
  display: flex;
  align-items: center;
  gap: calc(5 * var(--u));
  margin: 0;
  padding: calc(4 * var(--u));
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: calc(11 * var(--u));
  font-weight: 500;
  cursor: pointer;
  touch-action: manipulation;
}
.glyph,
.pillGlyph {
  display: grid;
  place-items: center;
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  border-radius: 50%;
  background: var(--glyph);
  color: var(--glyph-ink);
  font-size: calc(9.5 * var(--u));
  font-style: normal;
  font-weight: 700;
  transition:
    scale 70ms ease,
    box-shadow 160ms ease;
}
.cancel[data-held] .glyph,
.pill[data-held] .pillGlyph {
  scale: 0.82;
  box-shadow: 0 0 calc(8 * var(--u)) calc(2 * var(--u)) rgb(255 252 236 / 0.7);
}
.screen[data-phase='confirm'] .cancel,
.screen[data-phase='loading'] .cancel {
  opacity: 0.35;
  pointer-events: none;
}

/* ---- The question ---- */

.ask {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: center;
  justify-items: center;
  background: rgb(2 6 12 / 0.7);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 200ms ease,
    visibility 0s 200ms;
}
.ask[data-on] {
  opacity: 1;
  visibility: visible;
  transition: opacity 220ms ease;
}
/* The kit's modal plate: dark, with a hairline edge and thin corner brackets. */
.askPlate {
  position: relative;
  display: grid;
  justify-items: center;
  gap: calc(6 * var(--u));
  width: calc(300 * var(--u));
  padding: calc(22 * var(--u)) 0 calc(24 * var(--u));
  box-sizing: border-box;
  border: calc(1 * var(--u)) solid #3a3833;
  border-radius: calc(4 * var(--u));
  background: rgb(36 35 33 / 0.96);
  box-shadow: 0 calc(10 * var(--u)) calc(30 * var(--u)) rgb(0 0 0 / 0.5);
  translate: 0 calc(10 * var(--u));
  scale: 0.97;
  transition:
    translate 200ms ease,
    scale 200ms ease;
}
.askPlate::before {
  content: '';
  position: absolute;
  inset: calc(5 * var(--u));
  --c: rgb(220 216 195 / 0.45);
  --l: calc(10 * var(--u));
  --w: calc(1 * var(--u));
  background:
    linear-gradient(var(--c), var(--c)) 0 0 / var(--l) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 0 / var(--w) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0 / var(--l) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0 / var(--w) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100% / var(--l) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100% / var(--w) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100% / var(--l) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100% / var(--w) var(--l) no-repeat;
  pointer-events: none;
}
.ask[data-on] .askPlate {
  translate: 0 0;
  scale: 1;
  transition:
    translate 320ms cubic-bezier(0.2, 1.3, 0.4, 1),
    scale 320ms cubic-bezier(0.2, 1.3, 0.4, 1);
}
.askTitle,
.askSlot {
  margin: 0;
  text-align: center;
}
.askTitle {
  font-size: calc(17 * var(--u));
  font-weight: 500;
}
.askSlot {
  margin-bottom: calc(14 * var(--u));
  color: var(--ink-dim);
  font-size: calc(12 * var(--u));
}
.askSlot span {
  margin-left: calc(6 * var(--u));
  opacity: 0.75;
  font-size: calc(10 * var(--u));
}
.pills {
  display: grid;
  gap: calc(8 * var(--u));
}
/* A choice pill: black, with a dark line; the chosen one has the cream frame and a pointer. */
.pill {
  position: relative;
  display: flex;
  align-items: center;
  width: calc(160 * var(--u));
  height: calc(26 * var(--u));
  margin: 0;
  padding: 0 calc(12 * var(--u));
  box-sizing: border-box;
  border: calc(1 * var(--u)) solid var(--pill-line);
  border-radius: 999px;
  background: var(--pill);
  color: var(--ink);
  font: inherit;
  font-size: calc(12 * var(--u));
  font-weight: 500;
  cursor: pointer;
  outline: none;
  transition:
    border-color 140ms ease,
    box-shadow 140ms ease;
}
.pill[data-on] {
  border-color: var(--frame);
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) var(--frame-in),
    0 0 calc(6 * var(--u)) calc(1 * var(--u)) var(--frame-glow);
}
.pointer {
  position: absolute;
  left: calc(-11 * var(--u));
  width: calc(8 * var(--u));
  height: calc(10 * var(--u));
  background: var(--frame);
  clip-path: polygon(0 0, 100% 50%, 0 100%, 0 82%, 72% 50%, 0 18%);
  opacity: 0;
  translate: calc(-3 * var(--u)) 0;
  transition:
    opacity 120ms ease,
    translate 160ms ease;
}
.pill[data-on] .pointer {
  opacity: 1;
  translate: 0 0;
}
.pillGlyph {
  position: absolute;
  right: calc(-3 * var(--u));
  bottom: calc(-4 * var(--u));
  width: calc(11 * var(--u));
  height: calc(11 * var(--u));
  font-size: calc(7.5 * var(--u));
}

/* ---- Loading ---- */

.loading {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 380ms ease,
    visibility 0s 380ms;
}
.loading[data-on] {
  opacity: 1;
  visibility: visible;
  transition: opacity 420ms 120ms ease;
}
.spinner {
  position: absolute;
  left: calc(36 * var(--u));
  bottom: calc(30 * var(--u));
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  animation: spin 1100ms linear infinite;
}
.spinner i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(2.6 * var(--u));
  height: calc(2.6 * var(--u));
  margin: calc(-1.3 * var(--u));
  border-radius: 50%;
  background: var(--ink);
  rotate: calc(var(--d) * 60deg);
  translate: 0 0;
  transform: translateY(calc(-5.5 * var(--u)));
  opacity: calc(0.35 + var(--d) * 0.13);
}

/* ---- Focus, live region ---- */

.list:focus-visible .frameLine {
  outline: calc(1 * var(--u)) solid rgb(255 255 255 / 0.6);
  outline-offset: calc(5 * var(--u));
}
.cancel:focus-visible,
.pill:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}
.root:has(.list:focus-visible) .screen {
  box-shadow:
    0 0 0 2px var(--focus),
    0 calc(14 * var(--u)) calc(40 * var(--u)) var(--drop);
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---- Motion ---- */

.root[data-paused] .frameLine::after,
.root[data-paused] .spinner {
  animation-play-state: paused;
}
.root[data-reduced] *,
.root[data-reduced] *::after {
  animation: none !important;
  transition-duration: 0s !important;
  transition-delay: 0s !important;
}
.root[data-reduced] .frameLine::after {
  opacity: 0;
}

@keyframes fade {
  from {
    opacity: 0;
  }
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 calc(12 * var(--u));
  }
}
@keyframes breathe {
  from {
    opacity: 0.15;
  }
  to {
    opacity: 0.75;
  }
}
@keyframes flash {
  from {
    opacity: 1;
  }
}
@keyframes nudge-up {
  40% {
    translate: 0 calc(-4 * var(--u));
  }
}
@keyframes nudge-down {
  40% {
    translate: 0 calc(4 * var(--u));
  }
}
@keyframes spin {
  to {
    rotate: 1turn;
  }
}
```

### save-slots/scenes.tsx

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

/*
 * The slots' pictures: small painted landscapes drawn in SVG, one per scene, in place of the
 * screenshots a game would take when it saves. Each is a 260 × 120 drawing of layered shapes and
 * gradients (sky, far ground, near ground, light) with a tiny traveller in it, so a thumbnail reads
 * as "a moment of play" without any real game's art. The same drawing, scaled up and blurred by
 * the CSS, is the screen's backdrop for the selected slot.
 */

import type { CSSProperties } from 'react';

export type Scene = 'dunes' | 'arch' | 'docks' | 'cliff' | 'basin' | 'snow' | 'forest';
export const SCENES: Scene[] = ['dunes', 'arch', 'docks', 'cliff', 'basin', 'snow', 'forest'];

/** A small traveller seen from behind: hooded head, cloak, a pack and a staff. */
function Traveller({
  x,
  y,
  s = 1,
  cloak,
  skin = '#3a2a1e',
  lean = 0,
}: {
  x: number;
  y: number;
  s?: number;
  cloak: string;
  skin?: string;
  lean?: number;
}) {
  return (
    <g transform={`translate(${x} ${y}) scale(${s}) rotate(${lean})`}>
      <path d="M5.6-21.5 9.2 1.5" stroke={skin} strokeWidth="0.9" strokeLinecap="round" />
      <path
        d="M-1.8-6.5-2.6 0M1.8-6.5 2.4 0"
        stroke={skin}
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <path
        d="M-3.4-15.6C-3.9-11-3.6-8.6-4-6.3H4C3.6-8.6 3.9-11 3.4-15.6 1.6-17.1-1.6-17.1-3.4-15.6z"
        fill={cloak}
      />
      <path d="M-2.6-15.2h5.2l-.5 5.6H-2.1z" fill={skin} opacity="0.55" />
      <circle cx="0" cy="-18.6" r="2.4" fill={skin} />
      <path d="M-2.5-19.4C-2.2-21.6 2.2-21.6 2.5-19.4 1.6-20.2-1.6-20.2-2.5-19.4z" fill={cloak} />
    </g>
  );
}

function Dunes({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#fbf4d6" />
          <stop offset="1" stopColor="#f2dc9c" />
        </linearGradient>
        <radialGradient id={`${id}-sun`} cx="0.62" cy="0.18" r="0.6">
          <stop offset="0" stopColor="#fffdf2" />
          <stop offset="1" stopColor="#fffdf2" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${id}-sand`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#f6dfa0" />
          <stop offset="1" stopColor="#e3b764" />
        </linearGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-sky)`} />
      <path d="M0 52C40 46 70 50 110 47S190 40 260 46V70H0z" fill="#e6cd8e" opacity="0.7" />
      <path d="M200 30h3v26h-3zM196 34h11v2h-11z" fill="#c9b07a" opacity="0.55" />
      <path
        d="M0 40C14 36 30 37 46 41 60 38 76 40 90 45 70 50 40 52 0 52z"
        fill="#7d725b"
        opacity="0.75"
      />
      <path d="M8 44c16-3 34-2 52 2-18 3-36 3-52-2z" fill="#a39674" opacity="0.6" />
      <path d="M0 62C50 52 120 58 170 54S240 50 260 52V120H0z" fill={`url(#${id}-sand)`} />
      <path d="M0 78C60 70 130 80 260 70V120H0z" fill="#f4d58c" opacity="0.65" />
      <path d="M210 86c10-4 30-5 50-3v10c-18-2-36-2-50-7z" fill="#5d4b33" opacity="0.8" />
      <path
        d="M150 92l3-9 2 9 3-11 2 11 3-8 2 8 3-10 2 10"
        stroke="#a07b4b"
        strokeWidth="1.2"
        fill="none"
        opacity="0.7"
      />
      <rect width="260" height="120" fill={`url(#${id}-sun)`} />
      <Traveller x={102} y={96} s={1.5} cloak="#b2582d" skin="#5a3a22" lean={-4} />
    </>
  );
}

function Arch({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#7fa79a" />
          <stop offset="1" stopColor="#b9c7a6" />
        </linearGradient>
        <linearGradient id={`${id}-rock`} x1="0" y1="0" x2="0.4" y2="1">
          <stop offset="0" stopColor="#a8a271" />
          <stop offset="1" stopColor="#5f5c3c" />
        </linearGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-sky)`} />
      <path d="M150 60c20-12 50-14 80-8l30 4v30H150z" fill="#6f8e76" opacity="0.6" />
      <path d="M0 88C60 82 140 84 260 86V120H0z" fill="#cfcca1" />
      <path
        d="M-6 8C20-2 54 4 66 26 74 44 66 74 50 92 30 104 4 102-6 96z"
        fill={`url(#${id}-rock)`}
      />
      <path
        d="M44 14c34-14 80-14 112-2 24 10 30 36 22 58-6 14-22 18-28 6-8-18-30-30-56-28-20 2-34 8-46 4z"
        fill={`url(#${id}-rock)`}
      />
      <path d="M58 16c30-8 62-8 88 0" stroke="#c6c091" strokeWidth="3" fill="none" opacity="0.6" />
      <path d="M8 22c10-6 26-6 36 2" stroke="#c6c091" strokeWidth="3" fill="none" opacity="0.5" />
      <path d="M150 62c4 10 2 22-6 30" stroke="#4a4830" strokeWidth="2" fill="none" opacity="0.6" />
      <path d="M96 60c8-6 22-6 30 0v12H96z" fill="#3f6b55" opacity="0.7" />
      <path d="M86 106c14-3 34-3 48 0-14 3-34 3-48 0z" fill="#3b3a28" opacity="0.5" />
      <Traveller x={108} y={104} s={1.7} cloak="#3f7f62" skin="#3a2c20" />
    </>
  );
}

function Docks({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#4f6e69" />
          <stop offset="1" stopColor="#87a39a" />
        </linearGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-sky)`} />
      <path d="M0 44C40 36 90 40 130 44S220 40 260 42V70H0z" fill="#5f7d74" opacity="0.7" />
      <path d="M96 14h62l6 10H90z" fill="#3c4a43" />
      <path d="M98 24h58v34H98z" fill="#55605a" />
      <path d="M104 30h14v20h-14zM124 30h26v10h-26z" fill="#8b6f45" opacity="0.8" />
      <path d="M100 58h4v18h-4zM150 58h4v18h-4z" fill="#3c4a43" />
      <path d="M40 60l-24 22h14l22-20z" fill="#b8c0b3" />
      <path d="M58 58l-10 26h8l8-26z" fill="#9aa69b" />
      {[0, 1, 2, 3, 4, 5].map((i) => (
        <path
          key={i}
          d={`M0 ${74 + i * 8}C70 ${71 + i * 8} 170 ${73 + i * 8} 260 ${70 + i * 8}v6C170 ${79 + i * 8} 70 ${77 + i * 8} 0 ${80 + i * 8}z`}
          fill={i % 2 ? '#a8b3aa' : '#c3cbc0'}
        />
      ))}
      <path d="M0 74h260v46H0z" fill="#2f3b37" opacity="0.12" />
      <Traveller x={120} y={86} s={1.4} cloak="#6b5a3a" skin="#3a2e22" />
      <path d="M114 88c5 0 10 1 12 4" stroke="#2f3b37" strokeWidth="2" fill="none" opacity="0.4" />
    </>
  );
}

function Cliff({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-wall`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#d6a63e" />
          <stop offset="1" stopColor="#b07c24" />
        </linearGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-wall)`} />
      <path
        d="M0 20c40 4 90-2 140 4s90 2 120-2"
        stroke="#e4bb5c"
        strokeWidth="6"
        fill="none"
        opacity="0.35"
      />
      <path
        d="M0 50c50 6 120-4 260 2"
        stroke="#9d6c1e"
        strokeWidth="5"
        fill="none"
        opacity="0.25"
      />
      <path d="M180 8c-6 20 4 40-2 60" stroke="#9d6c1e" strokeWidth="3" fill="none" opacity="0.3" />
      <path d="M20 112L250 70l6 8L26 120z" fill="#6c5232" />
      <path d="M20 112L250 70l2 3L22 115z" fill="#8f7146" />
      <path d="M60 104l-4 8M120 93l-4 8M180 82l-4 8" stroke="#4f3b22" strokeWidth="2" />
      <Traveller x={128} y={66} s={1.6} cloak="#4f8a52" skin="#3b2c1f" lean={18} />
      <path d="M118 44l-8-10" stroke="#3b2c1f" strokeWidth="1.4" strokeLinecap="round" />
    </>
  );
}

function Basin({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#eadfc3" />
          <stop offset="1" stopColor="#dcc79a" />
        </linearGradient>
        <linearGradient id={`${id}-sand`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#ddbd7e" />
          <stop offset="1" stopColor="#c99d5a" />
        </linearGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-sky)`} />
      <path d="M0 30C40 22 70 28 120 26S210 22 260 26V44H0z" fill="#b9a27a" opacity="0.6" />
      <path d="M0 36C60 32 160 40 260 34V120H0z" fill={`url(#${id}-sand)`} />
      <path
        d="M0 60C80 54 160 66 260 58"
        stroke="#e8cf98"
        strokeWidth="8"
        fill="none"
        opacity="0.5"
      />
      <path
        d="M0 90C90 82 170 96 260 86"
        stroke="#b98d4c"
        strokeWidth="6"
        fill="none"
        opacity="0.35"
      />
      <path d="M8 118c2-16 12-24 22-20 6-10 18-8 22 2 10-2 14 8 10 18z" fill="#4e4630" />
      <path d="M16 104l4-10M30 100l2-12M44 102l6-10" stroke="#6b6040" strokeWidth="1.6" />
      <path d="M140 70l10 2-8 2z" fill="#8e6a3e" opacity="0.6" />
      <Traveller x={146} y={78} s={1.2} cloak="#7a4a2c" skin="#3e2c1f" />
    </>
  );
}

function Snow({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#c9d6df" />
          <stop offset="1" stopColor="#eef1f0" />
        </linearGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-sky)`} />
      <path d="M0 60L40 22 70 44 110 10 150 46 190 24 260 58V80H0z" fill="#8fa3b1" />
      <path d="M110 10l-14 14 8-2 6 8 6-10 8 6z" fill="#f4f6f6" />
      <path d="M40 22l-10 10 6-1 4 5 4-6 6 3z" fill="#f4f6f6" />
      <path d="M0 70C60 60 140 72 260 64V120H0z" fill="#f2f4f3" />
      <path d="M0 96C70 88 170 100 260 92" stroke="#d6dee3" strokeWidth="8" fill="none" />
      <path d="M180 80l6-14 6 14zM196 82l5-11 5 11z" fill="#4f6058" />
      <path d="M96 100c6 1 14 0 20-2" stroke="#b8c4cb" strokeWidth="2" fill="none" />
      <Traveller x={110} y={98} s={1.6} cloak="#3d5d84" skin="#2e2a28" />
    </>
  );
}

function Forest({ id }: { id: string }) {
  return (
    <>
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#14263a" />
          <stop offset="1" stopColor="#2b4a52" />
        </linearGradient>
        <radialGradient id={`${id}-glow`} cx="0.55" cy="0.72" r="0.35">
          <stop offset="0" stopColor="#ffcf7a" stopOpacity="0.75" />
          <stop offset="1" stopColor="#ffcf7a" stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-sky)`} />
      <circle cx="210" cy="22" r="7" fill="#e9eedb" opacity="0.85" />
      {[10, 46, 78, 172, 206, 238].map((x, i) => (
        <path
          key={x}
          d={`M${x} ${100 - (i % 2) * 8}l14-${58 + (i % 3) * 10} 14 ${58 + (i % 3) * 10}z`}
          fill="#16302c"
          opacity={0.75 + (i % 2) * 0.2}
        />
      ))}
      <path d="M0 96C80 90 170 100 260 92V120H0z" fill="#1d3a33" />
      <rect width="260" height="120" fill={`url(#${id}-glow)`} />
      <path d="M150 74v10" stroke="#1e1a14" strokeWidth="1.2" />
      <rect x="147" y="83" width="6" height="7" rx="1" fill="#ffd98c" />
      <Traveller x={136} y={102} s={1.5} cloak="#5c3f6e" skin="#221a18" />
    </>
  );
}

const DRAW: Record<Scene, (props: { id: string }) => React.ReactNode> = {
  dunes: Dunes,
  arch: Arch,
  docks: Docks,
  cliff: Cliff,
  basin: Basin,
  snow: Snow,
  forest: Forest,
};

/** One scene as an SVG that fills its box (cropped to cover it, as a photo would). */
export function SceneArt({
  scene,
  id,
  className,
  style,
}: {
  scene: Scene;
  id: string;
  className?: string;
  style?: CSSProperties;
}) {
  const Draw = DRAW[scene] ?? Dunes;
  return (
    <svg
      className={className}
      style={style}
      viewBox="0 0 260 120"
      preserveAspectRatio="xMidYMid slice"
      aria-hidden="true"
      focusable="false"
    >
      <Draw id={id} />
      <defs>
        <linearGradient id={`${id}-haze`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#fff" stopOpacity="0.16" />
          <stop offset="0.45" stopColor="#fff" stopOpacity="0.1" />
          <stop offset="1" stopColor="#fff" stopOpacity="0" />
        </linearGradient>
        <radialGradient id={`${id}-shade`} cx="0.5" cy="0.5" r="0.75">
          <stop offset="0.55" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#000" stopOpacity="0.28" />
        </radialGradient>
      </defs>
      <rect width="260" height="120" fill={`url(#${id}-haze)`} />
      <filter id={`${id}-grain`} x="0" y="0" width="100%" height="100%">
        <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="3" />
        <feColorMatrix values="0 0 0 0 0.5  0 0 0 0 0.45  0 0 0 0 0.35  0 0 0 0.12 0" />
      </filter>
      <rect width="260" height="120" filter={`url(#${id}-grain)`} />
      <rect width="260" height="120" fill={`url(#${id}-shade)`} />
    </svg>
  );
}
```

### save-slots/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. `visible` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```