# Settings screen

> A game’s Options screen: rows of ON/OFF toggles, ◀ value ▶ steppers and action buttons, a glowing cursor frame, and Restore and Back hints, driven by d-pad or keys.

The page a player opens mid-game to change a setting or two and get back to play. Each row is a label on a translucent strip and a control in a dark well: an ON/OFF toggle whose lit block slides, a stepper whose arrows dim at the ends of its list, or an action button. A cream frame with corner ticks glides to the selected row and breathes while it rests, a help line says what that setting does, disabled rows shake the frame when pushed, and Restore defaults flashes every row it changes. Up and down pick a row, left and right change it, on the keyboard or a gamepad.

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

Category: Games · Screens.

## Anatomy

1. **Screen**: The whole 1014 × 570-unit screen, always dark, with slow abstract art behind it; under 640 px wide it turns into a tall card at full size.
2. **Row**: A 420-unit row, 10.2 units from the next: an italic label on a translucent strip, and a control on its right. The selected row’s strip lightens.
3. **Toggle**: An ON/OFF switch (`role="switch"`) in a dark well: a lit block marks the value and slides between the two segments.
4. **Stepper**: A ◀ value ▶ slider over a list of named options; an arrow goes dim at that end of the list.
5. **Action**: A button in the same well, with one word; pressing it fires `onAction` with the row’s id.
6. **Selection frame**: One cream frame with corner ticks over the selected control, glowing and breathing; it glides from row to row.
7. **Help line**: What the selected setting does, centred under the list; `[A]` `[B]` `[X]` `[Y]` in it are drawn as button glyphs.
8. **Button hints**: Restore defaults (Y) and Back (B) in the bottom-right corner; each is a button as well as a key.

## When to use

- A game’s Options or Settings page, opened mid-game, where the player changes a setting or two and goes straight back to play.
- A list of discrete choices (on or off, a few named levels, a button that opens a sub-screen) driven by a d-pad, a stick or the arrow keys.
- Settings that need a line of explanation each, shown once for the selected row instead of crowding every row.
- A screen that must offer Restore defaults and Back as controller buttons, with the hints drawn in the corner as a console game does.
- A full-screen menu that keeps its own dark look on any page, light or dark.

## When not to use

- App or account settings on a website, used with a mouse. Use a form with switches, selects and a Save button.
- Continuous values the player tunes by feel, such as volume or field of view. Use a slider row with a number.
- A short pick of one option among a few, such as a difficulty at the start. Use a choice list.
- Many pages of settings in tabs (Controls, Audio, Display). Use menu tabs above several of these lists.
- A setting that needs free text, such as a name. Use a text field or an on-screen keyboard.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/settings-screen.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 { SettingsScreen, DEFAULT_ROWS, type SettingValues } from './settings-screen';
import { useState } from 'react';

export function Options({ onClose }: { onClose: () => void }) {
  const [values, setValues] = useState<SettingValues>({ 'invert-y': true, sensitivity: 'Normal' });
  return (
    <SettingsScreen
      rows={DEFAULT_ROWS}
      values={values}
      onChange={(id, value) => setValues((all) => ({ ...all, [id]: value }))}
      onAction={(id) => console.log('open', id)}
      onBack={onClose}
    />
  );
}
```

## States

- **Toggle selected**: The default: `swap-jump` selected, its strip lit and the frame on its switch; the help line draws [B] and [X] as glyphs.
- **Stepper selected**: `defaultSelected="sensitivity"`: the frame sits on the stepper, both arrows lit.
- **End of list**: At `Fastest` the right arrow goes dim; pushing on only bumps the value.
- **Action selected**: `defaultSelected="mapping"`: an action row, the first one; A, Enter or a click presses it.
- **Disabled row**: A row with `disabled` is faded but can still be selected, so its help line says why; changing it shakes the frame.
- **Scrolled**: The last row selected: the list has scrolled to keep it in view and the clipped top edge fades.
- **Changed values**: `defaultValues` set apart from the rows’ defaults; Restore (Y) brings them back, flashing each row it changes.
- **Short list**: Custom `rows` and `title`: four rows fit without scrolling, so no edge fades.
- **Narrow card**: Under 640 px wide: a tall card at full size, the labels wrap and the hints centre.
- **On a light page**: `colorScheme="light"`: the screen stays dark and sits on a soft shadow.
- **On a dark page**: `colorScheme="dark"`: a faint light edge instead of the shadow, and a white focus ring.

## How it works

- The screen is drawn at 1014 × 570 design units and every size is a multiple of one unit, --u = min(1px, 100cqi / 1014) from the component’s own width (or --settings-unit), so it scales as one picture: 420-unit rows 42.2 apart, a 205-unit label strip and a 215 × 28 control, 13.5-unit italic labels and a 24-unit title. Under 640 px wide it turns into a tall card at full size, with the labels wrapping and the controls taking just over half the row.
- A control is a translucent black well with a double rim (two inset shadows), so it picks up the strip under it: lighter on the selected row, as the kit draws it. The toggle splits about two thirds ON and one third OFF; its lit block is one layer whose clip-path inset moves from one segment to the other with a small overshoot, while the words swap brightness.
- The selection frame is a single element over the list, moved by translate to the selected control’s offsets (measured after each move and on resize) in 120 ms. It is a 1-unit cream line, a soft glow that breathes between 55 and 100 % opacity, and four light corner ticks cut by clip-path. The selected row’s lighter strip fades in under it.
- Moments are counters keyed into the elements that play them: a stepper’s new value remounts and slides in from the arrow’s side while that arrow flashes and nudges outwards; pushing past either end only bumps the value; an action flashes its well; a disabled row shakes the frame; Restore remounts a flash on every row it changes, 60 ms apart down the list; a footer glyph dips when its key is pressed.
- The rows scroll inside a viewport that keeps the selected one in view with a 12-unit margin, and fades its clipped edge with a mask only while there is more above or below. Opening plays once, when the screen is first seen: the title drops in, the rows slide in 35 ms apart and the hints rise.
- Input: the arrow keys, Enter, Space, A, Y, B and Escape while focus is inside; hover to pick a row and clicks on segments, arrows, buttons and hints; a gamepad’s d-pad, left stick and face buttons, polled each frame once one has connected, with the d-pad repeating after 380 ms. Uncontrolled, a demo visits the menu (sensitivity up to its end and back, a toggle, HUD mode, a locked row, a scroll, then Restore) while it is on screen, untouched and not focused.
- The screen keeps its own dark colours on any page, as a game does: its art is a few large radial gradients drifting very slowly, abstract and with no image, so nothing is upscaled or blurred. Only its footing on the page follows the scheme through light-dark(): a soft shadow on light, a faint light edge on dark, and the focus ring.

## API reference

- **rows** (`SettingRow[]`, default `DEFAULT_ROWS`): The rows, top to bottom: { id, label, description?, disabled? } plus type "toggle" with a boolean value, "stepper" with options and a value, or "action" with its button’s word. Each value is the default that Restore brings back; [A] [B] [X] [Y] in a description become glyphs.
- **values** (`Record<string, boolean | string>`): The current values by row id, controlled. Turns the demo off.
- **defaultValues** (`Record<string, boolean | string>`): Uncontrolled, the values to start from; the rows’ own values when left out.
- **onChange** (`(id: string, value: boolean | string) => void`): Called whenever a row’s value changes, including each row Restore sets back.
- **defaultSelected** (`string`, default `'swap-jump'`): The id of the row selected at first.
- **onSelect** (`(id: string) => void`): Called when the selection moves to a row.
- **onAction** (`(id: string) => void`): Called when an action row’s button is pressed.
- **onRestore** (`() => void`): Called after Restore defaults (Y) has set every row back.
- **onBack** (`() => void`): Called when Back is pressed: B, Escape, the hint or the gamepad’s bottom button.
- **title** (`string`, default `'Options'`): The screen’s heading.
- **restoreLabel** (`string`, default `'Restore default settings'`): The words of the Y hint.
- **backLabel** (`string`, default `'Back'`): The words of the B hint.
- **keys** (`boolean`, default `true`): Listen to the keyboard while focus is inside, and to a connected gamepad.
- **autoplay** (`boolean`, default `true`): Uncontrolled, a short visit to the menu plays while it is on screen, untouched and not focused; any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): The page around it, light or dark, or the reader’s; without it, the page’s color-scheme. The screen itself stays dark.
- **className** (`string`): Added to the root element.

## Accessibility

- The screen is a section named by its heading, and the rows are a list. Each control is real: a toggle is a switch button with aria-checked, a stepper a slider with its value as aria-valuetext, an action a button; each is named by its row’s label and described by the help line while selected.
- Focus is roving: only the selected row’s control is in the tab order, and up and down move focus with the selection (without scrolling the page), so Tab leaves the list for the two footer hints, which are buttons. The demo never moves focus, and stops while focus is inside.
- A disabled row stays reachable, with aria-disabled, so its help line can say why it is locked; it does not change.
- Changes are announced in a polite live region (“Camera sensitivity: Fast”, “Default settings restored”). The selection frame is the focus mark, with an outline under :focus-visible as well; the art and the glyphs in the hints are hidden from assistive technology, while glyphs inside the help line are read as their letters.
- Under reduced motion there is no demo, no opening, no drift or breathing, and values, the frame and the strips change at once.

## Source

### settings-screen/settings-screen.tsx

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

'use client';

import {
  Fragment,
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './settings-screen.module.css';

/*
 * Settings screen: a game's "Options" page, drawn as a whole screen (its own dark surface with
 * abstract warm art, no scene) with the list in the middle and the button hints in the corner.
 *
 * - Each row is a label on a translucent strip and a control in a dark well on its right: an
 *   ON/OFF toggle (the lit block marks the value), a ◀ value ▶ stepper (an arrow goes dim at the
 *   end of its list) or an action button. A disabled row is faded and refuses changes.
 * - One row is selected: its strip lightens and a cream frame with corner ticks, glowing and slowly
 *   breathing, sits on its control. The frame is one element that glides from row to row; the list
 *   scrolls to keep it in view, fading at the edge it clips. A help line under the list says what
 *   the selected setting does; [A] [B] [X] [Y] in it are drawn as button glyphs.
 * - Up and down pick a row (wrapping), left and right change its value, Enter, Space or A press it
 *   (flip a toggle, step a stepper on, fire an action), Y restores the defaults and B or Escape goes
 *   back. Hovering a row picks it; clicking a segment, an arrow or a button changes it; the footer
 *   hints are buttons too. A gamepad's d-pad, stick and face buttons work once one is connected.
 * - Changes animate the way a game menu does: the toggle's block slides, a stepper's arrow flashes
 *   and the new value slides in from its side (pushing past the end bumps it), an action flashes,
 *   a disabled row shakes its frame, and restoring flashes each row that changes, top to bottom.
 * - Uncontrolled, a demo plays a short visit to the menu while it is on screen and untouched; any
 *   input hands it to the reader. Given `values`, it shows those and reports changes by `onChange`.
 * - The screen is always dark, as a game's is; on a light page it sits on a soft shadow, on a dark
 *   one behind a faint light edge. Its sizes are a design unit, --u, that scales the 1014 × 570
 *   screen to the stage; narrower than 640 px it becomes a tall card at full size.
 */

type RowBase = {
  /** Stable id, the key in `values`. */
  id: string;
  label: string;
  /** The help line shown while the row is selected; [A] [B] [X] [Y] become button glyphs. */
  description?: string;
  /** Faded and locked: it can be selected (to read why) but not changed. */
  disabled?: boolean;
};
export type SettingRow =
  | (RowBase & { type: 'toggle'; value: boolean })
  | (RowBase & { type: 'stepper'; options: string[]; value: string })
  | (RowBase & { type: 'action'; action: string });
export type SettingValue = boolean | string;
export type SettingValues = Record<string, SettingValue>;

export const DEFAULT_ROWS: SettingRow[] = [
  {
    id: 'mapping',
    type: 'action',
    label: 'Button mapping',
    action: 'Customize',
    description: 'Choose which button does what.',
  },
  {
    id: 'invert-y',
    type: 'toggle',
    label: 'Invert vertical camera',
    value: false,
    description: 'Push the stick up to look down.',
  },
  {
    id: 'invert-x',
    type: 'toggle',
    label: 'Invert horizontal camera',
    value: false,
    description: 'Push the stick right to look left.',
  },
  {
    id: 'sensitivity',
    type: 'stepper',
    label: 'Camera sensitivity',
    options: ['Slowest', 'Slow', 'Normal', 'Fast', 'Fastest'],
    value: 'Fast',
    description: 'How quickly the camera turns with the right stick.',
  },
  {
    id: 'motion-aim',
    type: 'toggle',
    label: 'Aim with motion controls',
    value: true,
    description: 'Tilt the controller to fine-tune your aim.',
  },
  {
    id: 'swap-jump',
    type: 'toggle',
    label: 'Change jump button',
    value: true,
    description: 'Swap jump button [B] with cancel button [X]',
  },
  {
    id: 'north-map',
    type: 'toggle',
    label: 'Fix minimap to north',
    value: false,
    description: 'Keep north at the top of the minimap.',
  },
  {
    id: 'hud',
    type: 'stepper',
    label: 'HUD mode',
    options: ['Normal', 'Pro'],
    value: 'Normal',
    description: 'Pro hides everything but the essentials.',
  },
  {
    id: 'text-box',
    type: 'stepper',
    label: 'Text box transparency',
    options: ['Regular', 'Light', 'Clear'],
    value: 'Regular',
    description: 'How much of the world shows through dialogue.',
  },
  {
    id: 'voice',
    type: 'action',
    label: 'Voice language',
    action: 'Change',
    disabled: true,
    description: 'Voice language can only be changed from the title screen.',
  },
  {
    id: 'vibration',
    type: 'toggle',
    label: 'Controller vibration',
    value: true,
    description: 'Rumble when you land a hit, or take one.',
  },
];

type Move = 'up' | 'down' | 'left' | 'right' | 'confirm' | 'restore' | 'back';

/** The demo: moves at ms from its start. It starts from the defaults with `swap-jump` selected. */
const DEMO: [number, Move][] = [
  [900, 'up'],
  [1350, 'up'],
  [2100, 'right'],
  [2800, 'right'],
  [3500, 'left'],
  [4300, 'down'],
  [4700, 'down'],
  [5100, 'down'],
  [5800, 'left'],
  [6700, 'down'],
  [7400, 'right'],
  [8200, 'down'],
  [8900, 'right'],
  [9700, 'down'],
  [10400, 'confirm'],
  [11200, 'down'],
  [11900, 'confirm'],
  [13100, 'restore'],
  [14300, 'up'],
  [14650, 'up'],
  [15000, 'up'],
  [15350, 'up'],
  [15700, 'up'],
];
const DEMO_LENGTH = 17200;
const TOGGLE_ON = 'ON';
const TOGGLE_OFF = 'OFF';

/** Keys to moves. Letters only without modifiers, so the page's own shortcuts still work. */
function keyMove(event: React.KeyboardEvent): Move | null {
  if (event.metaKey || event.ctrlKey || event.altKey) return null;
  switch (event.key) {
    case 'ArrowUp':
      return 'up';
    case 'ArrowDown':
      return 'down';
    case 'ArrowLeft':
      return 'left';
    case 'ArrowRight':
      return 'right';
    case 'Enter':
    case ' ':
      return 'confirm';
    case 'Escape':
      return 'back';
  }
  const key = event.key.toLowerCase();
  if (key === 'a') return 'confirm';
  if (key === 'y') return 'restore';
  if (key === 'b') return 'back';
  return null;
}

/** A standard-layout gamepad, by position: bottom is B (back), right A, left Y; the d-pad. */
const PAD: [number, Move][] = [
  [0, 'back'],
  [1, 'confirm'],
  [2, 'restore'],
  [12, 'up'],
  [13, 'down'],
  [14, 'left'],
  [15, 'right'],
];

const initialValues = (rows: SettingRow[]): SettingValues =>
  Object.fromEntries(
    rows.filter((row) => row.type !== 'action').map((row) => [row.id, row.value as SettingValue]),
  );

const spoken = (row: SettingRow, value: SettingValue | undefined) =>
  row.type === 'toggle' ? (value ? 'On' : 'Off') : row.type === 'stepper' ? String(value) : '';

export function SettingsScreen({
  rows = DEFAULT_ROWS,
  values,
  defaultValues,
  onChange,
  defaultSelected = 'swap-jump',
  onSelect,
  onAction,
  onRestore,
  onBack,
  title = 'Options',
  restoreLabel = 'Restore default settings',
  backLabel = 'Back',
  keys = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The rows, top to bottom. Each one's `value` is its default, what Restore brings back. */
  rows?: SettingRow[];
  /** The current values by row id, controlled. Turns the demo off. */
  values?: SettingValues;
  /** Uncontrolled, the values to start from; the rows' defaults when left out. */
  defaultValues?: SettingValues;
  /** Called with a row's id and its new value, whenever it changes. */
  onChange?: (id: string, value: SettingValue) => void;
  /** The row selected at first, by id. */
  defaultSelected?: string;
  /** Called with a row's id when the selection moves to it. */
  onSelect?: (id: string) => void;
  /** Called with an action row's id when it is pressed. */
  onAction?: (id: string) => void;
  /** Called after Restore defaults has set every row back. */
  onRestore?: () => void;
  /** Called when Back is pressed (B, Escape, or the hint). */
  onBack?: () => void;
  /** The screen's heading. */
  title?: string;
  /** The words of the Y hint. */
  restoreLabel?: string;
  /** The words of the B hint. */
  backLabel?: string;
  /** Listen to the keyboard (while focus is inside) and to a connected gamepad. */
  keys?: boolean;
  /** While on screen and untouched, a short visit to the menu plays. */
  autoplay?: boolean;
  /** Light or dark page around it, or the reader's ('system'); the screen itself stays dark. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(8000);
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, '');
  const controlled = values !== undefined;
  // Focus inside means the reader is here: the demo waits until it leaves.
  const [within, setWithin] = useState(false);
  const demo = autoplay && playing && !controlled && !within;

  const [own, setOwn] = useState<SettingValues>(() => ({
    ...initialValues(rows),
    ...defaultValues,
  }));
  const now = values ?? own;
  const firstSelected = Math.max(
    0,
    rows.findIndex((row) => row.id === defaultSelected),
  );
  const [selected, setSelected] = useState(firstSelected);
  const index = Math.min(selected, rows.length - 1);
  const current = rows[index];

  // Moments, as counters keyed into the elements that animate them.
  const [nudge, setNudge] = useState<{
    id: string;
    side: 'left' | 'right';
    n: number;
    bump: boolean;
  }>({ id: '', side: 'right', n: 0, bump: false });
  const [hit, setHit] = useState({ id: '', n: 0 });
  const [deny, setDeny] = useState(0);
  const [restored, setRestored] = useState<{ ids: string[]; n: number }>({ ids: [], n: 0 });
  const [pressed, setPressed] = useState({ restore: 0, back: 0 });
  const [said, setSaid] = useState('');
  const [entered, setEntered] = useState(false);
  useEffect(() => {
    if (visible && !entered) setEntered(true);
  }, [visible, entered]);

  const setValue = (row: SettingRow, value: SettingValue) => {
    if (now[row.id] === value) return;
    if (!controlled) setOwn((all) => ({ ...all, [row.id]: value }));
    onChange?.(row.id, value);
    setSaid(`${row.label}: ${spoken(row, value)}`);
  };

  const select = (next: number, focus: boolean) => {
    if (next === index) return;
    setSelected(next);
    onSelect?.(rows[next].id);
    if (focus) {
      // Only when the reader is already in the list: the demo never takes focus.
      const active = document.activeElement;
      if (root.current?.contains(active) && active?.closest('[data-part="row"]')) {
        requestAnimationFrame(() =>
          root.current
            ?.querySelector<HTMLElement>(`[data-row-index="${next}"] [data-control]`)
            ?.focus({ preventScroll: true }),
        );
      }
    }
  };

  /** One move, from a key, the pad, a pointer or the demo. */
  const act = useEffectEvent((move: Move, at = index, focus = false) => {
    const row = rows[at];
    if (move === 'up' || move === 'down') {
      const step = move === 'up' ? -1 : 1;
      select((at + step + rows.length) % rows.length, focus);
      return;
    }
    if (move === 'restore') {
      setPressed((p) => ({ ...p, restore: p.restore + 1 }));
      const defaults = initialValues(rows);
      const changed = rows.filter((r) => r.type !== 'action' && now[r.id] !== defaults[r.id]);
      if (!controlled) setOwn((all) => ({ ...all, ...defaults }));
      changed.forEach((r) => onChange?.(r.id, defaults[r.id]));
      setRestored((r) => ({ ids: changed.map((c) => c.id), n: r.n + 1 }));
      setSaid('Default settings restored');
      onRestore?.();
      return;
    }
    if (move === 'back') {
      setPressed((p) => ({ ...p, back: p.back + 1 }));
      onBack?.();
      return;
    }
    if (!row) return;
    if (row.disabled) {
      setDeny((n) => n + 1);
      setSaid(`${row.label} can't be changed now`);
      return;
    }
    if (row.type === 'toggle') {
      const value = Boolean(now[row.id]);
      const next = move === 'left' ? true : move === 'right' ? false : !value;
      setValue(row, next);
    } else if (row.type === 'stepper') {
      const options = row.options;
      const at = Math.max(0, options.indexOf(String(now[row.id])));
      const side = move === 'left' ? 'left' : 'right';
      const target = at + (side === 'left' ? -1 : 1);
      const bump = target < 0 || target >= options.length;
      setNudge((n) => ({ id: row.id, side, n: n.n + 1, bump }));
      if (!bump) setValue(row, options[target]);
    } else if (move === 'confirm') {
      setHit((h) => ({ id: row.id, n: h.n + 1 }));
      onAction?.(row.id);
      setSaid(`${row.action}`);
    }
  });

  // The demo: from the defaults, a run of moves, again and again while it may play.
  const list = useRef<HTMLDivElement>(null);
  const viewport = useRef<HTMLDivElement>(null);
  const timers = useRef(new Set<number>());
  useEffect(() => {
    if (!demo) return;
    const all = timers.current;
    const run = () => {
      setOwn({ ...initialValues(rows), ...defaultValues });
      setSelected(firstSelected);
      viewport.current?.scrollTo({ top: 0, behavior: 'smooth' });
      for (const [at, move] of DEMO) {
        const timer = window.setTimeout(() => {
          all.delete(timer);
          act(move);
        }, at);
        all.add(timer);
      }
    };
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    return () => {
      window.clearInterval(loop);
      all.forEach((timer) => window.clearTimeout(timer));
      all.clear();
    };
  }, [demo]);

  const handOver = useCallback(() => take(), [take]);

  // Keys, while focus is inside.
  const onKeyDown = (event: React.KeyboardEvent) => {
    if (!keys) return;
    const move = keyMove(event);
    if (!move) return;
    const target = event.target as HTMLElement;
    if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
    // Enter and Space on a footer hint are its own click.
    if (move === 'confirm' && target.closest('[data-part="hint"]') && event.key !== 'a') return;
    event.preventDefault();
    handOver();
    act(move, index, true);
  };

  // A gamepad: polled once one is connected; the d-pad repeats while held, as menus do.
  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 Map<Move, number>());
  const poll = useEffectEvent((time: number) => {
    const down = new Set<Move>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      for (const [button, move] of PAD) if (gamepad.buttons[button]?.pressed) down.add(move);
      const [x = 0, y = 0] = gamepad.axes;
      if (y < -0.6) down.add('up');
      if (y > 0.6) down.add('down');
      if (x < -0.6) down.add('left');
      if (x > 0.6) down.add('right');
    });
    for (const move of held.current.keys()) if (!down.has(move)) held.current.delete(move);
    for (const move of down) {
      const next = held.current.get(move);
      const repeats = move === 'up' || move === 'down' || move === 'left' || move === 'right';
      if (next === undefined) {
        held.current.set(move, time + 380);
        handOver();
        act(move, index, true);
      } else if (repeats && time >= next) {
        held.current.set(move, time + 110);
        act(move, index, true);
      }
    }
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick(time) {
      poll(time);
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  // The frame follows the selected control; the list scrolls to keep it in view.
  const [frame, setFrame] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
  const [edges, setEdges] = useState({ above: false, below: false });
  const place = useCallback(() => {
    const control = list.current?.querySelector<HTMLElement>(
      `[data-row-index="${index}"] [data-control]`,
    );
    if (!control || !list.current) return;
    // Offsets up to the list (not rects: the rows may be mid-animation).
    let x = 0;
    let y = 0;
    for (let at: HTMLElement | null = control; at && at !== list.current;) {
      x += at.offsetLeft;
      y += at.offsetTop;
      at = at.offsetParent as HTMLElement | null;
    }
    setFrame({ x, y, w: control.offsetWidth, h: control.offsetHeight });
  }, [index]);
  useLayoutEffect(place, [place]);
  useEffect(() => {
    const element = list.current;
    if (!element) return;
    const observer = new ResizeObserver(place);
    observer.observe(element);
    return () => observer.disconnect();
  }, [place]);

  const readEdges = useCallback(() => {
    const box = viewport.current;
    if (!box) return;
    setEdges({
      above: box.scrollTop > 1,
      below: box.scrollTop + box.clientHeight < box.scrollHeight - 1,
    });
  }, []);
  useEffect(() => {
    const box = viewport.current;
    const row = list.current?.querySelector<HTMLElement>(`[data-row-index="${index}"]`);
    if (!box || !row) return;
    const margin = 12;
    const top = row.offsetTop - margin;
    const bottom = row.offsetTop + row.offsetHeight + margin - box.clientHeight;
    const to = top < box.scrollTop ? top : bottom > box.scrollTop ? bottom : null;
    if (to !== null) box.scrollTo({ top: to, behavior: reduced ? 'auto' : 'smooth' });
    readEdges();
  }, [index, reduced, readEdges]);
  useEffect(() => {
    const box = viewport.current;
    if (!box) return;
    const observer = new ResizeObserver(readEdges);
    observer.observe(box);
    return () => observer.disconnect();
  }, [readEdges]);

  const helpId = `${uid}-help`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onKeyDown={onKeyDown}
      onPointerDown={controlled ? undefined : handOver}
      onFocus={() => setWithin(true)}
      onBlur={(event) => {
        if (!root.current?.contains(event.relatedTarget as Node)) setWithin(false);
      }}
    >
      <section
        className={styles.screen}
        data-part="screen"
        data-entered={entered && !reduced ? '' : undefined}
        data-paused={visible ? undefined : ''}
        aria-labelledby={`${uid}-title`}
        aria-keyshortcuts={
          keys ? 'ArrowUp ArrowDown ArrowLeft ArrowRight Enter Y B Escape' : undefined
        }
      >
        <div className={styles.art} aria-hidden="true">
          <span className={styles.haze} />
          <span className={styles.ember} />
          <span className={styles.cool} />
          <span className={styles.band} />
        </div>

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

        <div
          ref={viewport}
          className={styles.viewport}
          data-part="viewport"
          data-above={edges.above || undefined}
          data-below={edges.below || undefined}
          onScroll={readEdges}
        >
          <div ref={list} className={styles.list} role="list" data-part="list">
            {frame && (
              <span
                className={styles.frame}
                data-part="frame"
                aria-hidden="true"
                style={
                  {
                    '--fx': `${frame.x}px`,
                    '--fy': `${frame.y}px`,
                    '--fw': `${frame.w}px`,
                    '--fh': `${frame.h}px`,
                  } as CSSProperties
                }
              >
                <span key={deny} className={styles.frameShake} data-deny={deny ? '' : undefined}>
                  <span className={styles.frameGlow} />
                  <span className={styles.frameLine} />
                  <span className={styles.corner} data-corner="tl" />
                  <span className={styles.corner} data-corner="tr" />
                  <span className={styles.corner} data-corner="bl" />
                  <span className={styles.corner} data-corner="br" />
                </span>
              </span>
            )}
            {rows.map((row, i) => (
              <Row
                key={row.id}
                row={row}
                index={i}
                order={i}
                selected={i === index}
                value={now[row.id]}
                labelId={`${uid}-label-${i}`}
                helpId={i === index && row.description ? helpId : undefined}
                nudge={nudge.id === row.id ? nudge : null}
                hit={hit.id === row.id ? hit.n : 0}
                flash={restored.ids.indexOf(row.id)}
                flashKey={restored.n}
                onPick={() => {
                  if (i !== index) select(i, false);
                }}
                onMove={(move) => {
                  handOver();
                  if (i !== index) select(i, false);
                  act(move, i);
                }}
              />
            ))}
          </div>
        </div>

        <p
          key={current?.id}
          id={helpId}
          className={styles.help}
          data-part="help"
          aria-hidden={current?.description ? undefined : true}
        >
          {current?.description ? withGlyphs(current.description) : ' '}
        </p>

        <div className={styles.hints} data-part="hints">
          <button
            type="button"
            className={styles.hint}
            data-part="hint"
            aria-keyshortcuts={keys ? 'Y' : undefined}
            onClick={() => {
              handOver();
              act('restore');
            }}
          >
            <span className={styles.hintWord}>{restoreLabel}</span>
            <Glyph letter="Y" press={pressed.restore} />
          </button>
          <button
            type="button"
            className={styles.hint}
            data-part="hint"
            aria-keyshortcuts={keys ? 'B Escape' : undefined}
            onClick={() => {
              handOver();
              act('back');
            }}
          >
            <span className={styles.hintWord}>{backLabel}</span>
            <Glyph letter="B" press={pressed.back} />
          </button>
        </div>
      </section>
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}

/** One row: the label strip and its control. */
function Row({
  row,
  index,
  order,
  selected,
  value,
  labelId,
  helpId,
  nudge,
  hit,
  flash,
  flashKey,
  onPick,
  onMove,
}: {
  row: SettingRow;
  index: number;
  order: number;
  selected: boolean;
  value: SettingValue | undefined;
  labelId: string;
  helpId: string | undefined;
  nudge: { side: 'left' | 'right'; n: number; bump: boolean } | null;
  hit: number;
  flash: number;
  flashKey: number;
  onPick: () => void;
  onMove: (move: Move) => void;
}) {
  const common = {
    'data-control': '',
    tabIndex: selected ? 0 : -1,
    'aria-labelledby': labelId,
    'aria-describedby': helpId,
    'aria-disabled': row.disabled || undefined,
    onFocus: onPick,
  };

  let control: ReactNode;
  if (row.type === 'toggle') {
    const on = Boolean(value);
    control = (
      <button
        type="button"
        role="switch"
        aria-checked={on}
        className={`${styles.control} ${styles.toggle}`}
        data-part="toggle"
        data-on={on || undefined}
        {...common}
        onClick={(event) => {
          // A click lands on a segment: that one is the value. Keys are handled above.
          if (event.detail === 0) return;
          const segment = (event.target as HTMLElement).closest<HTMLElement>('[data-segment]');
          onMove(segment?.dataset.segment === 'off' ? 'right' : 'left');
        }}
      >
        <span className={styles.lit} aria-hidden="true" />
        <span className={styles.segment} data-segment="on" data-active={on || undefined}>
          {TOGGLE_ON}
        </span>
        <span className={styles.segment} data-segment="off" data-active={!on || undefined}>
          {TOGGLE_OFF}
        </span>
      </button>
    );
  } else if (row.type === 'stepper') {
    const at = Math.max(0, row.options.indexOf(String(value)));
    const first = at === 0;
    const last = at === row.options.length - 1;
    control = (
      <div
        role="slider"
        className={`${styles.control} ${styles.stepper}`}
        data-part="stepper"
        aria-valuemin={0}
        aria-valuemax={row.options.length - 1}
        aria-valuenow={at}
        aria-valuetext={String(value)}
        {...common}
      >
        <Arrow
          side="left"
          dim={first}
          hit={nudge && nudge.side === 'left' && !nudge.bump ? nudge.n : 0}
          onPress={() => onMove('left')}
        />
        <span className={styles.valueBox}>
          <span
            key={nudge ? `${value}-${nudge.bump ? nudge.n : 0}` : String(value)}
            className={styles.value}
            data-from={nudge && !nudge.bump ? nudge.side : undefined}
            data-bump={nudge?.bump ? nudge.side : undefined}
          >
            {String(value)}
          </span>
        </span>
        <Arrow
          side="right"
          dim={last}
          hit={nudge && nudge.side === 'right' && !nudge.bump ? nudge.n : 0}
          onPress={() => onMove('right')}
        />
      </div>
    );
  } else {
    control = (
      <button
        type="button"
        className={`${styles.control} ${styles.action}`}
        data-part="action"
        {...common}
        onClick={(event) => {
          if (event.detail === 0) return;
          onMove('confirm');
        }}
      >
        {hit > 0 && <span key={hit} className={styles.press} aria-hidden="true" />}
        <span className={styles.actionWord}>{row.action}</span>
      </button>
    );
  }

  return (
    <div
      role="listitem"
      className={styles.row}
      data-part="row"
      data-row-index={index}
      data-selected={selected || undefined}
      data-disabled={row.disabled || undefined}
      style={{ '--i': order } as CSSProperties}
      onPointerEnter={(event) => {
        if (event.pointerType === 'mouse') onPick();
      }}
      onPointerDown={(event) => {
        if (event.pointerType !== 'mouse') onPick();
      }}
    >
      <span id={labelId} className={styles.label} data-part="label">
        {row.label}
      </span>
      <span className={styles.slot}>
        {control}
        {flash >= 0 && (
          <span
            key={flashKey}
            className={styles.flash}
            style={{ '--k': flash } as CSSProperties}
            aria-hidden="true"
          />
        )}
      </span>
    </div>
  );
}

/** A stepper arrow: a grey box with a cream triangle, dim at the end of the list. */
function Arrow({
  side,
  dim,
  hit,
  onPress,
}: {
  side: 'left' | 'right';
  dim: boolean;
  hit: number;
  onPress: () => void;
}) {
  return (
    <span
      className={styles.arrow}
      data-part="arrow"
      data-side={side}
      data-dim={dim || undefined}
      aria-hidden="true"
      onClick={(event) => {
        event.stopPropagation();
        onPress();
      }}
    >
      <span key={hit} className={styles.arrowFace} data-hit={hit ? '' : undefined}>
        <svg className={styles.arrowIcon} viewBox="0 0 8 12">
          <path d={side === 'left' ? 'M7.5 0.5v11L0.5 6z' : 'M0.5 0.5v11L7.5 6z'} />
        </svg>
      </span>
    </span>
  );
}

/** A face button glyph: a cream disc with its letter, glowing; it dips on each press. */
function Glyph({ letter, press, small }: { letter: string; press?: number; small?: boolean }) {
  return (
    <span
      key={press}
      className={styles.glyph}
      data-part="glyph"
      data-small={small || undefined}
      data-press={press ? '' : undefined}
      aria-hidden={small ? undefined : true}
      role={small ? 'img' : undefined}
      aria-label={small ? letter : undefined}
    >
      {letter}
    </span>
  );
}

/** A help line, with [A] [B] [X] [Y] drawn as glyphs. */
function withGlyphs(text: string) {
  return text
    .split(/(\[[ABXY]\])/)
    .map((part, i) =>
      /^\[[ABXY]\]$/.test(part) ? (
        <Glyph key={i} letter={part[1]} small />
      ) : (
        <Fragment key={i}>{part}</Fragment>
      ),
    );
}
```

### settings-screen/settings-screen.module.css

```css
/* Settings screen (settings-screen.tsx): a game's Options page as a whole screen: its own dark
   surface with soft abstract art, the title, the list of rows in a scrolling viewport, the help
   line and the button hints in the bottom-right corner.

   Sizes are multiples of --u, one design unit (1 px at full size): the screen is drawn at 1014 × 570
   units and --u scales it to the stage (min(1px, 100cqi / 1014), or --settings-unit when
   set). A row is 420 × 32 units, 10.2 apart: a 205-unit label strip and a 215 × 28 control.
   Narrower than 640 px, the screen becomes a tall card at full size and the rows fill its width.

   Colours: the screen is always dark, as a game's is: translucent strips over the art, near-black
   wells with a double rim, warm greys for the lit parts, a cream frame. Only what sits on the page
   (the screen's shadow or edge, the focus ring) follows the scheme, through light-dark().

   Motion: the frame glides between rows (a transform transition) and breathes (opacity keyframes);
   the toggle's lit block slides by clip-path; a stepper's value slides in from its side; presses,
   denials and restores are keyframes keyed per event. The opening plays once, staggered by --i. */

.root {
  /* The screen. */
  --screen: #1a1814;
  --ink: #e2ded3;
  --strip: rgb(64 62 57 / 0.5);
  --strip-on: linear-gradient(90deg, rgb(150 140 124 / 0.5), rgb(136 129 117 / 0.44));
  --well: rgb(8 7 6 / 0.62);
  --well-line: #0f0e0c;
  --rim: #272522;
  --lit: #65635c;
  --lit-on: #6d6c65;
  --off-ink: #46443f;
  --off-ink-on: #4b4a45;
  --box: #64625c;
  --box-on: #6f6d66;
  --box-dim: #1f1d1a;
  --tri-dim: #373632;
  --frame: #d4cdab;
  --frame-glow: rgb(236 228 196 / 0.6);
  --tick: #efebdf;
  --glyph: #e2ded3;
  --glyph-ink: #1b1a17;
  --flash: rgb(255 250 235 / 0.55);
  /* On the page. */
  --lift: 0 22px 44px -22px rgb(0 0 0 / 0.55), 0 2px 6px rgb(0 0 0 / 0.12);
  --focus: #1b1a18;
  --u: var(--settings-unit, min(1px, calc(100cqi / 1014)));
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  box-sizing: border-box;
  width: 100%;
  padding: 16px;
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --lift: light-dark(0 22px 44px -22px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(255 255 255 / 0.1));
    --focus: light-dark(#1b1a18, #fff);
  }
}
.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 {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  box-sizing: border-box;
  width: calc(1014 * var(--u));
  height: calc(570 * var(--u));
  padding: calc(54 * var(--u)) calc(63 * var(--u)) 0 calc(61 * var(--u));
  overflow: hidden;
  border-radius: 10px;
  background: var(--screen);
  box-shadow: var(--lift);
  font-size: calc(14 * var(--u));
  line-height: 1;
  user-select: none;
  -webkit-user-select: none;
}
/* Offscreen or in a hidden tab, the slow loops stop. */
.screen[data-paused] .art > span,
.screen[data-paused] .frameGlow {
  animation-play-state: paused;
}
@container (max-width: 639px) {
  .screen {
    --u: 1px;
    width: 100%;
    height: auto;
    padding: 26px 12px 16px;
  }
}

/* The art: soft fields of light over a dark ground, abstract, drifting very slowly. */
.art {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  background: linear-gradient(180deg, #24211c 0%, #1d1b17 62%, #171714 100%);
  pointer-events: none;
}
.art > span {
  position: absolute;
  border-radius: 50%;
}
.haze {
  left: -20%;
  top: -35%;
  width: 85%;
  height: 120%;
  background: radial-gradient(closest-side, rgb(64 58 49 / 0.7), transparent);
  animation: drift 26s ease-in-out infinite alternate;
}
.ember {
  left: -12%;
  top: 48%;
  width: 52%;
  height: 70%;
  background: radial-gradient(closest-side, rgb(104 52 30 / 0.42), transparent);
  animation: drift 31s ease-in-out -9s infinite alternate-reverse;
}
.cool {
  right: -14%;
  top: 6%;
  width: 46%;
  height: 90%;
  background: radial-gradient(closest-side, rgb(10 16 22 / 0.75), transparent);
  animation: drift 37s ease-in-out -4s infinite alternate;
}
.art > .band {
  inset: auto 0 0;
  height: 18%;
  border-radius: 0;
  background: linear-gradient(180deg, transparent, rgb(20 20 17 / 0.85) 70%);
}

.title {
  margin: 0;
  color: var(--ink);
  font-size: calc(24 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(30 * var(--u));
  letter-spacing: -0.005em;
  text-shadow: 0 0 calc(10 * var(--u)) rgb(0 0 0 / 0.45);
}

/* The rows scroll inside the viewport; its padding leaves room for the frame's glow. */
.viewport {
  flex: none;
  box-sizing: border-box;
  width: calc(436 * var(--u));
  max-width: calc(100% + 16 * var(--u));
  height: calc(424 * var(--u));
  margin: calc(15 * var(--u)) calc(-8 * var(--u)) 0;
  padding: calc(6 * var(--u)) calc(8 * var(--u));
  overflow: hidden auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
  --fade: calc(44 * var(--u));
}
.viewport::-webkit-scrollbar {
  display: none;
}
.viewport[data-below] {
  mask-image: linear-gradient(180deg, #000 calc(100% - var(--fade)), rgb(0 0 0 / 0.25));
}
.viewport[data-above] {
  mask-image: linear-gradient(0deg, #000 calc(100% - var(--fade)), rgb(0 0 0 / 0.25));
}
.viewport[data-above][data-below] {
  mask-image: linear-gradient(
    180deg,
    rgb(0 0 0 / 0.25),
    #000 var(--fade),
    #000 calc(100% - var(--fade)),
    rgb(0 0 0 / 0.25)
  );
}
@container (max-width: 639px) {
  .viewport {
    width: calc(100% + 16px);
    max-width: none;
    height: auto;
    max-height: 424px;
    margin-top: 16px;
  }
}

.list {
  position: relative;
  display: grid;
  gap: calc(10.2 * var(--u));
}

/* ---- A row ---- */

.row {
  position: relative;
  display: grid;
  grid-template-columns: calc(205 * var(--u)) calc(215 * var(--u));
  align-items: center;
  min-height: calc(32 * var(--u));
  background: var(--strip);
}
.row::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--strip-on);
  opacity: 0;
  transition: opacity 140ms ease-out;
}
.row[data-selected]::before {
  opacity: 1;
  transition-duration: 70ms;
}
.row[data-disabled] {
  opacity: 0.45;
}
@container (max-width: 639px) {
  .row {
    grid-template-columns: minmax(0, 1fr) min(215px, 54%);
  }
}

.label {
  position: relative;
  overflow: hidden;
  padding: calc(4 * var(--u)) calc(6 * var(--u)) calc(4 * var(--u)) calc(8 * var(--u));
  color: var(--ink);
  font-size: calc(13.5 * var(--u));
  font-style: italic;
  line-height: 1.2;
  white-space: nowrap;
  text-overflow: ellipsis;
}
@container (max-width: 639px) {
  .label {
    white-space: normal;
    font-size: 13px;
  }
}

.slot {
  position: relative;
  display: grid;
  height: calc(28 * var(--u));
  margin: calc(2 * var(--u)) 0;
}

/* The control: a dark well with a double rim. Buttons and the slider share its look. */
.control {
  position: relative;
  display: grid;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: calc(3 * var(--u));
  border: 0;
  border-radius: calc(2 * var(--u));
  background: var(--well);
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) var(--well-line),
    inset 0 0 0 calc(2 * var(--u)) var(--rim);
  color: var(--ink);
  font: inherit;
  font-weight: 470;
  line-height: 1;
  cursor: pointer;
  outline: none;
  touch-action: manipulation;
}
.control:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(5 * var(--u));
}
.row[data-disabled] .control {
  cursor: default;
}

/* The toggle: ON takes about two thirds, OFF the rest; a lit block marks the value. */
.toggle {
  grid-template-columns: 64.1% 1fr;
}
.lit {
  position: absolute;
  inset: calc(3 * var(--u));
  border-radius: calc(1.5 * var(--u));
  background: var(--lit);
  clip-path: inset(0 0 0 64.6% round calc(1.5 * var(--u)));
  transition:
    clip-path 230ms cubic-bezier(0.3, 1.25, 0.5, 1),
    background-color 120ms;
}
.toggle[data-on] .lit {
  clip-path: inset(0 35.9% 0 0 round calc(1.5 * var(--u)));
}
.row[data-selected] .lit {
  background: var(--lit-on);
}
.segment {
  position: relative;
  display: grid;
  place-items: center;
  color: var(--off-ink);
  transition: color 160ms ease-out;
}
.segment[data-active] {
  color: var(--ink);
  transition-delay: 50ms;
}
.row[data-selected] .segment:not([data-active]) {
  color: var(--off-ink-on);
}

/* The stepper: arrows in grey boxes at both ends, the value between them. */
.stepper {
  grid-template-columns: calc(22 * var(--u)) minmax(0, 1fr) calc(22 * var(--u));
  cursor: default;
}
.arrow {
  display: grid;
  cursor: pointer;
}
.arrow[data-dim] {
  cursor: default;
}
.arrowFace {
  display: grid;
  place-items: center;
  border-radius: calc(1.5 * var(--u));
  background: var(--box);
  color: var(--ink);
  transition: background-color 120ms;
}
.row[data-selected] .arrowFace {
  background: var(--box-on);
}
.arrow[data-dim] .arrowFace {
  background: var(--box-dim);
  color: var(--tri-dim);
}
.arrowFace[data-hit] {
  animation: arrow-hit 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.arrow[data-side='left'] .arrowFace {
  --out: calc(-2 * var(--u));
}
.arrow[data-side='right'] .arrowFace {
  --out: calc(2 * var(--u));
}
.arrowIcon {
  width: calc(7.5 * var(--u));
  height: calc(11.5 * var(--u));
  fill: currentColor;
}
.valueBox {
  display: grid;
  place-items: center;
  overflow: hidden;
}
.value {
  display: block;
  white-space: nowrap;
}
.value[data-from='right'] {
  --from: calc(16 * var(--u));
  animation: value-in 220ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.value[data-from='left'] {
  --from: calc(-16 * var(--u));
  animation: value-in 220ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.value[data-bump='right'] {
  --to: calc(5 * var(--u));
  animation: bump 260ms cubic-bezier(0.3, 0, 0.3, 1);
}
.value[data-bump='left'] {
  --to: calc(-5 * var(--u));
  animation: bump 260ms cubic-bezier(0.3, 0, 0.3, 1);
}

/* The action: a word in the well; a press flashes it. */
.action {
  place-items: center;
}
.actionWord {
  position: relative;
}
.press,
.flash {
  position: absolute;
  inset: calc(1 * var(--u));
  border-radius: calc(2 * var(--u));
  background: var(--flash);
  opacity: 0;
  pointer-events: none;
}
.press {
  animation: press 380ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
/* Restoring: each row that changes flashes, one after the other. */
.flash {
  animation: press 520ms calc(var(--k) * 60ms) cubic-bezier(0.2, 0.7, 0.3, 1);
}

/* ---- The frame on the selected control ---- */

.frame {
  position: absolute;
  z-index: 2;
  left: 0;
  top: 0;
  width: calc(var(--fw) + 2 * var(--u));
  height: calc(var(--fh) + 2 * var(--u));
  translate: calc(var(--fx) - 1 * var(--u)) calc(var(--fy) - 1 * var(--u));
  pointer-events: none;
  transition:
    translate 120ms cubic-bezier(0.25, 0.9, 0.3, 1),
    height 120ms cubic-bezier(0.25, 0.9, 0.3, 1);
}
.frameShake {
  position: absolute;
  inset: 0;
}
.frameShake[data-deny] {
  animation: deny 320ms cubic-bezier(0.3, 0, 0.3, 1);
}
.frameGlow,
.frameLine {
  position: absolute;
  inset: 0;
  border-radius: calc(3 * var(--u));
}
.frameGlow {
  box-shadow:
    0 0 calc(5 * var(--u)) calc(1.5 * var(--u)) var(--frame-glow),
    inset 0 0 calc(3 * var(--u)) var(--frame-glow);
  animation: breathe 1800ms ease-in-out infinite alternate;
}
.frameLine {
  box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--frame);
}
/* The corner ticks: small light triangles in each corner. */
.corner {
  position: absolute;
  width: calc(6.5 * var(--u));
  height: calc(6.5 * var(--u));
  background: var(--tick);
}
.corner[data-corner='tl'] {
  left: 0;
  top: 0;
  clip-path: polygon(0 0, 100% 0, 0 100%);
}
.corner[data-corner='tr'] {
  right: 0;
  top: 0;
  clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.corner[data-corner='bl'] {
  left: 0;
  bottom: 0;
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}
.corner[data-corner='br'] {
  right: 0;
  bottom: 0;
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
}

/* ---- The help line and the hints ---- */

.help {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0 calc(4 * var(--u));
  min-height: calc(16 * var(--u));
  margin: calc(-6 * var(--u)) 0 0;
  color: var(--ink);
  font-size: calc(12 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(16 * var(--u));
  text-align: center;
  text-shadow: 0 0 calc(5 * var(--u)) rgb(0 0 0 / 0.7);
  animation: help-in 220ms ease-out;
}
@container (max-width: 639px) {
  .help {
    /* Two lines held, so the hints don't jump as a help text wraps or not. */
    align-content: center;
    min-height: 32px;
    margin-top: 10px;
    font-size: 12.5px;
  }
}

.hints {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-self: flex-end;
  gap: calc(6 * var(--u)) calc(30 * var(--u));
  margin-top: calc(3 * var(--u));
  margin-right: calc(3 * var(--u));
}
@container (max-width: 639px) {
  .hints {
    align-self: stretch;
    justify-content: center;
    margin: 14px 0 0;
  }
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--u));
  margin: 0;
  padding: calc(2 * var(--u)) 0;
  border: 0;
  border-radius: 4px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: calc(11.6 * var(--u));
  cursor: pointer;
  touch-action: manipulation;
}
.hint:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
.hintWord {
  font-style: italic;
  font-weight: 500;
  text-shadow:
    0 0 calc(5 * var(--u)) rgb(226 222 211 / 0.45),
    0 0 calc(2 * var(--u)) rgb(0 0 0 / 0.5);
}
.glyph {
  display: inline-grid;
  place-items: center;
  flex: none;
  width: calc(17 * var(--u));
  height: calc(17 * var(--u));
  border-radius: 50%;
  background: var(--glyph);
  box-shadow: 0 0 calc(7 * var(--u)) calc(1 * var(--u)) rgb(255 248 228 / 0.42);
  color: var(--glyph-ink);
  font-size: calc(10.5 * var(--u));
  font-style: normal;
  font-weight: 650;
  line-height: 1;
  text-shadow: none;
}
.glyph[data-small] {
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  font-size: calc(10 * var(--u));
}
.glyph[data-press] {
  animation: glyph-press 420ms cubic-bezier(0.3, 1.6, 0.5, 1);
}

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

/* ---- The opening, once, when the screen is first seen ---- */

.screen[data-entered] .title {
  animation: title-in 420ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.screen[data-entered] .row {
  animation: row-in 360ms calc(120ms + var(--i) * 35ms) cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.screen[data-entered] .frame {
  animation: fade-in 260ms 420ms ease-out both;
}
.screen[data-entered] .hints {
  animation: rise-in 360ms 480ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}

@keyframes title-in {
  from {
    opacity: 0;
    translate: 0 calc(-8 * var(--u));
  }
}
@keyframes row-in {
  from {
    opacity: 0;
    translate: calc(-14 * var(--u)) 0;
  }
}
@keyframes rise-in {
  from {
    opacity: 0;
    translate: 0 calc(6 * var(--u));
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes help-in {
  from {
    opacity: 0;
  }
}
@keyframes breathe {
  from {
    opacity: 0.55;
  }
  to {
    opacity: 1;
  }
}
@keyframes deny {
  0%,
  100% {
    translate: 0 0;
  }
  20% {
    translate: calc(-4 * var(--u)) 0;
  }
  45% {
    translate: calc(3.5 * var(--u)) 0;
  }
  70% {
    translate: calc(-2 * var(--u)) 0;
  }
  88% {
    translate: calc(1 * var(--u)) 0;
  }
}
@keyframes value-in {
  from {
    opacity: 0;
    translate: var(--from) 0;
  }
}
@keyframes bump {
  40% {
    translate: var(--to) 0;
  }
}
@keyframes arrow-hit {
  from {
    background-color: var(--ink);
    color: var(--glyph-ink);
    translate: var(--out) 0;
  }
}
@keyframes press {
  12% {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
@keyframes glyph-press {
  from {
    scale: 0.78;
    box-shadow: 0 0 calc(12 * var(--u)) rgb(255 248 228 / 0.95);
  }
}
@keyframes drift {
  to {
    translate: 4% 3%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .art > span,
  .frameGlow {
    animation: none;
  }
  .row::before,
  .lit,
  .segment,
  .frame,
  .arrowFace {
    transition: none;
  }
  .value[data-from],
  .value[data-bump],
  .arrowFace[data-hit],
  .frameShake[data-deny],
  .glyph[data-press],
  .press,
  .flash,
  .help {
    animation: none;
  }
}
```

### settings-screen/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 };
}
```