# Choice list

> A game’s dialog answers: dark pills with a pointer arrow on the focused one, a quantity stepper and a cancel answer that B picks.

The short stack of answers a game hands the player beside a line of dialog. The cursor’s pill turns darker with a light outline and a warm glow, and a hollow arrow on its edge glides from answer to answer and bobs while it waits. A quantity answer is a stepper, ◀ 3 ▶, that left and right change between its limits; a way out wears a small B badge, and B picks it from anywhere. The pills arrive one after the other, and a pick dips and flashes the chosen one while the rest fall away. Up and down, left and right, A and B work from the keys, a pointer or a gamepad, and each pill carries its own dark ground, so the stack reads on a light page and a dark one.

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

Category: Games · Dialogs.

## Anatomy

1. **Stack**: The `role="menu"` stack that takes focus and the keys: one grid column at least 188 units wide, so every pill shares the widest one’s width.
2. **Pill**: A dark answer 34 units tall with a fully round end, a grey inner edge and a faint dark rim; 41 units from one to the next.
3. **Focus glow**: The focused pill’s look, a layer inside it that fades in: a darker ground, a light 1-unit outline and a warm glow inside and out.
4. **Label**: The answer’s words, 16 units in italic, 12 units in from the pill’s left end.
5. **Pointer**: One hollow arrow for the whole stack, its tip on the focused pill’s edge: it glides to the next with an overshoot and bobs while it waits.
6. **Stepper**: A quantity answer, ◀ n ▶: two solid arrows with a minus and a plus cut in, either side of a value chip. At a limit its arrow dims.
7. **B badge**: A light disc 16 units across on the cancel answer’s lower right corner: B or Escape picks that answer from anywhere.
8. **Key chips**: The keys the stack listens to, in small chips under it, with `keys` on; hidden when the component is under 480 px wide.

## When to use

- The player’s answers beside a line of dialog: two or three short replies, picked with up, down and A.
- A yes-or-no moment in a game: accept a quest, take an item, sleep until morning.
- Choosing how many of something to buy, sell or drop, with a ◀ n ▶ stepper that left and right change between its limits.
- Any in-game question with a way out: a cancel answer wears a B badge, and B or Escape picks it from anywhere.
- A prompt that has to work the same from a pad, the keys and a pointer, and read on a light scene or a dark one.

## When not to use

- Long menus of more than three or four entries, or entries with icons and counts. Use a menu list or the item grid.
- Choices in a web app or a form. Use radio buttons, a segmented control or plain buttons with words.
- A message the player only reads and dismisses. Use a dialog box with an advance chevron.
- A decision that interrupts the whole game with a title and a body. Use the game modal.
- Picking a number from a wide range (1 to 99) or typing one. Use a slider or a number field; the stepper is for a handful.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/choice-list.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 { ChoiceList, type ChoiceOption } from './choice-list';

const answers: ChoiceOption[] = [
  { id: 'count', label: 'How many', kind: 'stepper', value: 1, min: 1, max: 9 },
  { id: 'back', label: 'Never mind', kind: 'cancel' },
];

export function Shop({ open, onDone }: { open: boolean; onDone: () => void }) {
  return (
    <ChoiceList
      options={answers}
      open={open}
      onConfirm={(option, value) => {
        console.log('buy', value);
        onDone();
      }}
      onCancel={onDone}
    />
  );
}
```

## States

- **Two answers**: The kit’s pair, the cursor on the first: the pointer sits on its left edge.
- **Cursor moved**: `focused={1}`: the glow and the pointer on the second answer; the first is back to plain.
- **Quantity**: A `stepper` answer at 3 of 1 to 5 above a way out: left and right change it, A takes it.
- **At a limit**: The stepper at its `max`: the right arrow dims, and pressing on shakes the chip instead.
- **Way out focused**: Three answers, the cursor on the `cancel` one, with its B badge on the lower right corner.
- **Long answer**: One long label widens the shared column, so every pill takes its width.
- **No keys**: `keys={false}`: no key chips, and the keyboard and pad are not listened to; the pointer still works.
- **Light**: `colorScheme="light"`: a dark edge round the arrow and the badge, and dark key chips. The pills never change.
- **Dark**: `colorScheme="dark"`: the edges go, and the key chips turn light.

## How it works

- Every size is a multiple of one design unit, --u, worked out from the component’s width with a container query unit (min(1.5px, (100cqi − 32px) / 214), or --choices-unit when set), so the stack scales as one drawing and keeps the kit’s proportions: pills 188 × 34 units, 41 apart, the label 16 units in italic from 12 in; a 13 × 19 arrow with its tip on the pill’s edge; stepper arrows of 8 × 14 either side of a 46 × 22 chip; a 16-unit B disc centred 3 units in from the pill’s lower right corner.
- All pills share one grid column at least 188 units wide, so a long answer widens the whole stack and every pill keeps the same width; past the component’s width, the label ends in an ellipsis instead of leaving its pill. The focused look (a darker ground, a 1-unit light outline, a warm inset glow and a faint halo) is a layer inside each pill that fades in and out, so moving the cursor cross-fades two pills instead of repainting shadows.
- One arrow serves the stack: it is translated by the focused index times 41 units, with an overshooting transition, so it glides between answers; its drawing bobs 2.5 units in a slow loop and pokes forward when an answer is picked.
- On arrival the pills play a keyframe from 18 units to the right, staggered 45 ms apart by their index, and the arrow follows. A pick dips the chosen pill to 0.96 with a white flash over it while the others fade and drop, then the stack fades and slides away; uncontrolled, it comes back 700 ms later, so the reader can play again.
- A stepper’s value is remounted on each change, sliding in from the side that was pressed while that arrow kicks out and glows; at a limit the arrow dims and the chip shakes instead. B or Escape flashes the B badge and picks the cancel answer at once.
- Input: the arrow keys, Enter, Space, A, Escape, Backspace and B while the stack has focus; hover moves the cursor and a click picks (the stepper’s arrows and the badge are clickable); a gamepad, once one connects, is polled each frame, with held directions repeating after 380 ms every 110 ms. Uncontrolled, a demo plays three exchanges (a yes or no, a quantity with a way out, three answers left with B) while it is on screen and untouched; any input hands it over.
- There is no scene behind it: the pills carry their own dark grounds and keep the kit’s colours on any page. Only what sits straight on the page follows the scheme through light-dark() tokens: a dark edge round the light arrow and badge on a light page, the focus outline and the key chips.

## API reference

- **options** (`ChoiceOption[]`): The answers, two or three as a game shows them: { id, label, kind?, value?, min?, max?, step? }. kind is choice (default), cancel (B badge, picked by B) or stepper (◀ n ▶; its label is only read out). A new set of ids plays the arrival again. Turns the demo off.
- **focused** (`number`): The cursor’s index, controlled.
- **defaultFocused** (`number`, default `0`): The cursor’s first index, uncontrolled.
- **onFocusChange** (`(index: number) => void`): Called when the cursor moves, from any input.
- **onConfirm** (`(option: ChoiceOption, value?: number) => void`): Called when an answer is picked; a stepper passes its value.
- **onCancel** (`(option: ChoiceOption) => void`): Called when the cancel answer is picked, by B, Escape or confirming it.
- **onStep** (`(option: ChoiceOption, value: number) => void`): Called when a stepper’s value changes.
- **open** (`boolean`): Shown or not, controlled: false plays the stack away, true brings it back with its arrival. Without it, the stack returns on its own after a pick.
- **keys** (`boolean`, default `true`): Listen to the keyboard while the stack has focus, and to a connected gamepad; also shows the key chips underneath.
- **label** (`string`, default `'Choices'`): The stack’s accessible name.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, three short exchanges play (moves, a quantity, picks and a cancel). Any input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The pills keep their colours on both; the arrow’s edge, focus and key chips follow it. Set --choices-unit on the root (or above it) to fix the size: 1px is the kit’s.
- **className** (`string`): Added to the root.

## Accessibility

- The stack is a focusable menu named by label, with aria-activedescendant pointing at the focused answer, so screen readers follow the cursor while focus stays on the stack; each answer is a menuitem, a cancel one named with “(B)”, and a stepper named with its value, range and how to change it.
- Keys work only while the stack has focus and are listed in aria-keyshortcuts; Escape is left to the page when there is no cancel answer.
- A polite live region reads stepper changes (“How many: 4”) and each pick (“Chose: Never mind”).
- Focus is an outline round the stack, dark on a light page and white on a dark one. The arrow, badge and stepper art are hidden from assistive technology.
- Under reduced motion the demo does not play and nothing slides, glides, bobs or shakes: the cursor and values change at once, and a pick shows as a flash only.

## Source

### choice-list/choice-list.tsx

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

'use client';

import {
  Fragment,
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './choice-list.module.css';

/*
 * Choice list: the short stack of answers a game hands the player beside a line of dialog. The
 * cursor sits on one pill; up and down move it, A confirms, B backs out.
 *
 * - Every answer is a dark pill of the same width (the kit's 188 × 34 units, wider for a long
 *   label, up to the component's width, past which the label ends in an ellipsis), 41 units
 *   apart. The focused one turns darker, with a light outline and a warm glow
 *   inside it, and a hollow pointer arrow sits on its left edge. One pointer serves the whole stack:
 *   it glides from pill to pill with a small overshoot and bobs while it waits.
 * - Kinds: a 'choice' is a plain answer; a 'cancel' answer wears a small B badge on its lower right
 *   corner, and B (or Escape) picks it from anywhere; a 'stepper' is a quantity, ◀ 3 ▶, that left
 *   and right change between its min and max: the arrow on that side kicks, the number slides
 *   over, and at a limit the chip shakes.
 * - Moments: the pills arrive one after the other from the right; confirming dips and flashes the
 *   chosen pill while the others fall away, then the stack leaves. Uncontrolled, it comes back a
 *   moment later, so it can be played again; with `open`, the game decides.
 * - Input: the arrow keys while the stack has focus, Enter, Space or A to confirm,
 *   Escape, Backspace or B to cancel; the pointer (hover moves the cursor, a click confirms, the
 *   stepper's arrows and the B badge are clickable); a gamepad's d-pad, stick, A and B once one is
 *   connected, held directions repeating.
 * - Uncontrolled, a demo plays three exchanges (a yes or no, a quantity with a way out, three
 *   answers left with B) while it is on screen and untouched; any input hands it to the reader.
 * - The pills carry their own dark ground and keep their colours on a light page and a dark one,
 *   as a game's do; only what sits straight on the page (the pointer's edge, focus, key chips)
 *   follows the scheme. `--choices-unit` fixes the size.
 */

export type ChoiceKind = 'choice' | 'cancel' | 'stepper';
export type ChoiceOption = {
  /** Stable id, reported back on confirm. */
  id: string;
  /** The answer's words; for a stepper, its name for screen readers (it shows only the number). */
  label: string;
  /** 'choice' (default), 'cancel' (B badge, picked by B) or 'stepper' (◀ n ▶). */
  kind?: ChoiceKind;
  /** A stepper's first value (default its min). */
  value?: number;
  /** A stepper's range (default 1 to 9) and step (default 1). */
  min?: number;
  max?: number;
  step?: number;
};

type Action = 'up' | 'down' | 'left' | 'right' | 'confirm' | 'cancel';

const KIT: ChoiceOption[] = [
  { id: 'yes', label: 'Sure, take it' },
  { id: 'no', label: 'Not right now' },
];
const QUANTITY: ChoiceOption[] = [
  { id: 'count', label: 'How many', kind: 'stepper', value: 3, min: 1, max: 5 },
  { id: 'back', label: 'Never mind', kind: 'cancel' },
];
const THREE: ChoiceOption[] = [
  { id: 'bridge', label: 'Ask about the bridge' },
  { id: 'storm', label: 'Ask about the storm' },
  { id: 'leave', label: 'Be on my way', kind: 'cancel' },
];
type DemoStep = { at: number; set?: ChoiceOption[]; act?: Action };
const DEMO: DemoStep[] = [
  { at: 0, set: KIT },
  { at: 1400, act: 'down' },
  { at: 2200, act: 'up' },
  { at: 3000, act: 'confirm' },
  { at: 4300, set: QUANTITY },
  { at: 5600, act: 'right' },
  { at: 6000, act: 'right' },
  { at: 6400, act: 'right' },
  { at: 7000, act: 'left' },
  { at: 7700, act: 'down' },
  { at: 8400, act: 'up' },
  { at: 9100, act: 'confirm' },
  { at: 10400, set: THREE },
  { at: 11700, act: 'down' },
  { at: 12400, act: 'down' },
  { at: 13000, act: 'up' },
  { at: 13900, act: 'cancel' },
];
const DEMO_LENGTH = 15300;

/** From confirm to the stack leaving, and from leaving to gone. */
const FLASH = 360;
const LEAVE = 240;
/** Uncontrolled, how long the stack stays away before it comes back. */
const AWAY = 700;
/** Held gamepad directions: first repeat, then every. */
const REPEAT_FIRST = 380;
const REPEAT_EVERY = 110;

const LEGEND = [
  ['↑', '↓', 'move'],
  ['←', '→', 'amount'],
  ['A', 'pick'],
  ['B', 'back'],
];

function keyAction(event: React.KeyboardEvent): Action | 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 ' ':
    case 'a':
    case 'A':
      return 'confirm';
    case 'Escape':
    case 'Backspace':
    case 'b':
    case 'B':
      return 'cancel';
  }
  return null;
}

const kindOf = (option: ChoiceOption) => option.kind ?? 'choice';
const rangeOf = (option: ChoiceOption) => {
  const min = option.min ?? 1;
  const max = Math.max(min, option.max ?? 9);
  return { min, max, step: option.step ?? 1 };
};
const startValues = (options: ChoiceOption[]) =>
  Object.fromEntries(
    options
      .filter((option) => kindOf(option) === 'stepper')
      .map((option) => [option.id, option.value ?? rangeOf(option).min]),
  ) as Record<string, number>;

type Phase = 'live' | 'picked' | 'gone';
type Kick = { side: 'left' | 'right'; n: number; dead: boolean };

export function ChoiceList({
  options,
  focused,
  defaultFocused = 0,
  onFocusChange,
  onConfirm,
  onCancel,
  onStep,
  open,
  keys = true,
  label = 'Choices',
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The answers, two or three as a game shows them. Without it, the kit's pair (and the demo). */
  options?: ChoiceOption[];
  /** The cursor's index, controlled. */
  focused?: number;
  /** The cursor's first index, uncontrolled. */
  defaultFocused?: number;
  /** Called when the cursor moves, from any input. */
  onFocusChange?: (index: number) => void;
  /** Called when an answer is confirmed; a stepper passes its value. */
  onConfirm?: (option: ChoiceOption, value?: number) => void;
  /** Called when the cancel answer is picked (by B, Escape, or confirming it). */
  onCancel?: (option: ChoiceOption) => void;
  /** Called when a stepper's value changes. */
  onStep?: (option: ChoiceOption, value: number) => void;
  /** Shown or not, controlled; without it the stack comes back on its own after a pick. */
  open?: boolean;
  /** Listen to the keyboard (while focused) and to a connected gamepad; shows the key chips. */
  keys?: boolean;
  /** The stack's accessible name. */
  label?: string;
  /** While on screen and untouched, a few exchanges play. Any input stops it. */
  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>(8000);
  const baseId = `choice-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
  const controlledSet = options !== undefined;
  const demo = autoplay && playing && !controlledSet && open === undefined && focused === undefined;

  const [own, setOwn] = useState<ChoiceOption[]>(KIT);
  const list = options ?? own;
  const [round, setRound] = useState(0);
  const [phase, setPhase] = useState<Phase>('live');
  const [picked, setPicked] = useState<number | null>(null);
  const [ownFocus, setOwnFocus] = useState(defaultFocused);
  const [values, setValues] = useState(() => startValues(list));
  const [kicks, setKicks] = useState<Record<string, Kick>>({});
  const [badgeHits, setBadgeHits] = useState(0);
  const [announce, setAnnounce] = useState('');

  const count = list.length;
  const clamp = (index: number) => Math.min(Math.max(index, 0), Math.max(count - 1, 0));
  const at = clamp(focused ?? ownFocus);
  const shown: Phase = open === false ? 'gone' : phase;

  // Timers that belong to the stack's own sequences (a pick, a return).
  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);
  };
  const clearTimers = () => {
    timers.current.forEach((timer) => window.clearTimeout(timer));
    timers.current.clear();
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

  /** A fresh entrance: the pills arrive again, the cursor on its first answer. */
  const reopen = useEffectEvent((next?: ChoiceOption[]) => {
    const set = next ?? list;
    if (next) setOwn(next);
    setValues(startValues(set));
    setKicks({});
    setPicked(null);
    setPhase('live');
    setOwnFocus(next ? 0 : defaultFocused);
    setRound((n) => n + 1);
  });

  // The set changing from outside (other ids) is a new exchange.
  const idsKey = list.map((option) => option.id).join('|');
  const lastIds = useRef(idsKey);
  useEffect(() => {
    if (lastIds.current === idsKey) return;
    lastIds.current = idsKey;
    if (controlledSet) reopen();
  }, [idsKey, controlledSet]);

  // `open` turning true again is a new entrance.
  const lastOpen = useRef(open);
  useEffect(() => {
    if (lastOpen.current === false && open) reopen();
    lastOpen.current = open;
  }, [open]);

  const moveTo = (index: number, quiet = false) => {
    if (index === at) return;
    if (focused === undefined) setOwnFocus(index);
    if (!quiet) onFocusChange?.(index);
  };

  const commit = (index: number, quiet: boolean) => {
    const option = list[index];
    if (!option) return;
    setPicked(index);
    setPhase('picked');
    const value = kindOf(option) === 'stepper' ? values[option.id] : undefined;
    setAnnounce(kindOf(option) === 'stepper' ? `Chose ${value}` : `Chose: ${option.label}`);
    later(FLASH, () => setPhase('gone'));
    if (!quiet) {
      if (kindOf(option) === 'cancel') onCancel?.(option);
      else onConfirm?.(option, value);
      if (open === undefined) later(FLASH + LEAVE + AWAY, () => reopen());
    }
  };

  const step = (index: number, side: 'left' | 'right', quiet: boolean) => {
    const option = list[index];
    if (!option || kindOf(option) !== 'stepper') return;
    const { min, max, step: by } = rangeOf(option);
    const now = values[option.id] ?? min;
    const next = Math.min(max, Math.max(min, now + (side === 'right' ? by : -by)));
    setKicks((all) => ({
      ...all,
      [option.id]: { side, n: (all[option.id]?.n ?? 0) + 1, dead: next === now },
    }));
    if (next === now) return;
    setValues((all) => ({ ...all, [option.id]: next }));
    setAnnounce(`${option.label}: ${next}`);
    if (!quiet) onStep?.(option, next);
  };

  /** Every input lands here: keys, pointer, gamepad and the demo. */
  const act = useEffectEvent((action: Action, quiet = false) => {
    if (shown !== 'live' || count === 0) return;
    if (action === 'up') moveTo((at - 1 + count) % count, quiet);
    else if (action === 'down') moveTo((at + 1) % count, quiet);
    else if (action === 'left' || action === 'right') step(at, action, quiet);
    else if (action === 'confirm') commit(at, quiet);
    else {
      const cancel = list.findIndex((option) => kindOf(option) === 'cancel');
      if (cancel < 0) return;
      setBadgeHits((n) => n + 1);
      moveTo(cancel, quiet);
      commit(cancel, quiet);
    }
  });

  // The demo: three exchanges, again and again while it may play.
  const demoTimers = useRef(new Set<number>());
  useEffect(() => {
    if (!demo) return;
    const all = demoTimers.current;
    const run = () => {
      for (const s of DEMO) {
        const timer = window.setTimeout(() => {
          all.delete(timer);
          if (s.set) reopen(s.set);
          if (s.act) act(s.act, true);
        }, s.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]);

  /** Real input: the demo stops, and a stack it left away comes back. */
  const handOver = () => {
    take();
    if (demo || demoTimers.current.size) {
      demoTimers.current.forEach((timer) => window.clearTimeout(timer));
      demoTimers.current.clear();
      if (phase !== 'live') {
        clearTimers();
        reopen();
      }
    }
  };

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (!keys) return;
    const action = keyAction(event);
    if (!action) return;
    if (event.key === 'Escape' && !list.some((o) => kindOf(o) === 'cancel')) return;
    event.preventDefault();
    handOver();
    if (event.repeat && (action === 'confirm' || action === 'cancel')) return;
    act(action);
  };

  // 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 padHeld = useRef(new Map<Action, number>());
  const poll = useEffectEvent((now: number) => {
    const down = new Set<Action>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      const b = (i: number) => gamepad.buttons[i]?.pressed;
      const [x = 0, y = 0] = gamepad.axes;
      if (b(12) || y < -0.5) down.add('up');
      if (b(13) || y > 0.5) down.add('down');
      if (b(14) || x < -0.5) down.add('left');
      if (b(15) || x > 0.5) down.add('right');
      if (b(1)) down.add('confirm');
      if (b(0)) down.add('cancel');
    });
    for (const action of [...padHeld.current.keys()]) {
      if (!down.has(action)) padHeld.current.delete(action);
    }
    for (const action of down) {
      const next = padHeld.current.get(action);
      if (next === undefined) {
        handOver();
        act(action);
        padHeld.current.set(action, now + REPEAT_FIRST);
      } else if (now >= next && action !== 'confirm' && action !== 'cancel') {
        act(action);
        padHeld.current.set(action, now + REPEAT_EVERY);
      }
    }
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick(now) {
      poll(now);
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  const optionId = (index: number) => `${baseId}-${round}-${index}`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      role="group"
      aria-label={label}
      onPointerDown={handOver}
    >
      <div className={styles.stage}>
        <div
          className={styles.stack}
          data-part="stack"
          data-phase={shown}
          role="menu"
          aria-label={label}
          aria-orientation="vertical"
          aria-activedescendant={shown === 'live' && count ? optionId(at) : undefined}
          aria-keyshortcuts={
            keys ? 'ArrowUp ArrowDown ArrowLeft ArrowRight Enter Escape' : undefined
          }
          tabIndex={0}
          onKeyDown={onKeyDown}
          style={{ '--count': count, '--at': at } as CSSProperties}
        >
          {list.map((option, index) => {
            const kind = kindOf(option);
            const value = values[option.id];
            const kick = kicks[option.id];
            const range = kind === 'stepper' ? rangeOf(option) : null;
            return (
              <div
                key={`${round}-${option.id}`}
                id={optionId(index)}
                className={styles.pill}
                data-part="pill"
                data-kind={kind}
                data-focused={index === at || undefined}
                data-picked={picked === index || undefined}
                data-other={picked !== null && picked !== index ? '' : undefined}
                role="menuitem"
                aria-label={
                  kind === 'stepper'
                    ? `${option.label}: ${value}, from ${range!.min} to ${range!.max}; left and right change it`
                    : kind === 'cancel'
                      ? `${option.label} (B)`
                      : option.label
                }
                style={{ '--i': index } as CSSProperties}
                onPointerMove={(event) => {
                  if (event.pointerType === 'touch' || shown !== 'live') return;
                  if (index !== at) {
                    handOver();
                    moveTo(index);
                  }
                }}
                onClick={() => {
                  handOver();
                  if (shown !== 'live') return;
                  if (kind === 'stepper' && index !== at) moveTo(index);
                  else commit(index, false);
                }}
              >
                <span className={styles.glow} data-part="glow" aria-hidden="true" />
                {kind === 'stepper' ? (
                  <span className={styles.stepper} data-part="stepper" aria-hidden="true">
                    <StepArrow
                      side="left"
                      kick={kick?.side === 'left' ? kick : undefined}
                      off={value <= range!.min}
                      onStep={() => {
                        handOver();
                        moveTo(index);
                        step(index, 'left', false);
                      }}
                    />
                    <span
                      key={kick?.dead ? `shake-${kick.n}` : 'chip'}
                      className={styles.chip}
                      data-part="value"
                      data-shake={kick?.dead || undefined}
                    >
                      <span
                        key={value}
                        className={styles.value}
                        data-from={kick && !kick.dead ? kick.side : undefined}
                      >
                        {value}
                      </span>
                    </span>
                    <StepArrow
                      side="right"
                      kick={kick?.side === 'right' ? kick : undefined}
                      off={value >= range!.max}
                      onStep={() => {
                        handOver();
                        moveTo(index);
                        step(index, 'right', false);
                      }}
                    />
                  </span>
                ) : (
                  <span className={styles.label} data-part="label">
                    {option.label}
                  </span>
                )}
                {kind === 'cancel' && (
                  <span
                    key={badgeHits}
                    className={styles.badge}
                    data-part="b-badge"
                    data-hit={badgeHits ? '' : undefined}
                    aria-hidden="true"
                  >
                    B
                  </span>
                )}
              </div>
            );
          })}
          <span key={round} className={styles.marker} data-part="marker" aria-hidden="true">
            <svg className={styles.markerArt} viewBox="0 0 15 21">
              <path className={styles.markerEdge} d="M1.6 1.6 13.4 10.5 1.6 19.4z" />
              <path className={styles.markerLine} d="M1.6 1.6 13.4 10.5 1.6 19.4z" />
              <path className={styles.markerCore} d="M4.4 7.6 8.6 10.5 4.4 13.4z" />
            </svg>
          </span>
        </div>
      </div>
      {keys && (
        <p className={styles.legend} aria-hidden="true" data-part="legend">
          {LEGEND.map((group, index) => (
            <Fragment key={index}>
              {index > 0 && <span className={styles.legendGap} />}
              {group.slice(0, -1).map((key) => (
                <kbd key={key}>{key}</kbd>
              ))}
              <span>{group[group.length - 1]}</span>
            </Fragment>
          ))}
        </p>
      )}
      <p className={styles.live} aria-live="polite">
        {announce}
      </p>
    </div>
  );
}

/** A stepper arrow: a solid triangle with a small minus or plus cut into its base. */
function StepArrow({
  side,
  kick,
  off,
  onStep,
}: {
  side: 'left' | 'right';
  kick: Kick | undefined;
  off: boolean;
  onStep: () => void;
}) {
  return (
    <span
      className={styles.step}
      data-part={`step-${side}`}
      data-side={side}
      data-off={off || undefined}
      onClick={(event) => {
        event.stopPropagation();
        onStep();
      }}
    >
      <svg
        key={kick ? kick.n : 0}
        className={styles.stepArt}
        data-kick={kick && !kick.dead ? '' : undefined}
        viewBox="0 0 8 14"
      >
        <g transform={side === 'right' ? 'translate(8 0) scale(-1 1)' : undefined}>
          <path className={styles.stepShape} d="M0 7 8 0v14z" />
          <path
            className={styles.stepMark}
            d={side === 'left' ? 'M3.4 7h4' : 'M3.4 7h4M5.6 4.9v4.2'}
          />
        </g>
      </svg>
    </span>
  );
}
```

### choice-list/choice-list.module.css

```css
/* Choice list (choice-list.tsx): a stack of dialog answers, alone on the page's stage, with the
   keyboard keys behind them in small chips underneath.

   Every size is a multiple of --u, one design unit (the kit's pixel): the root sets it from its own
   width (a container query unit), up to 1.5 px, so the stack scales as one drawing; --choices-unit
   fixes it. The kit's measures: pills 188 × 34 (wider for a long label), 41 apart, the label 16 in
   from 12; the pointer arrow 13 × 19 with its tip on the pill's edge; a stepper's arrows 7 × 14
   either side of a 46 × 22 chip; the B badge a 16 disc centred 3 in from the pill's lower right.

   Colours: the pills keep the kit's on any page (dark grounds, a grey edge, a light outline and a
   warm inner glow on the focused one), as a game's do. What sits straight on the page follows the
   scheme through light-dark(): the dark edge round the pointer and the badge (needed on a light
   page, not on a dark one), focus, the key chips.

   Motion: pills arrive by keyframes staggered by --i; the pointer glides by a transition of
   translate keyed to --at, with an overshoot, and bobs by a loop; the focused look is a layer that
   fades in; a pick is a keyframe dip and flash, the others fall by transition, then the stack
   leaves. Stepper kicks, the number's slide and the limit shake are keyframes remounted by key. */

.root {
  --ink: #e2ded3;
  --pill: #33322e;
  --pill-line: #5c5a54;
  --pill-rim: rgb(22 21 19 / 0.55);
  --pill-on: #1f1e1c;
  --on-line: #d2cec4;
  --on-glow: rgb(128 122 92 / 0.75);
  --on-halo: rgb(62 58 40 / 0.55);
  --chip: rgb(255 250 235 / 0.08);
  --mark: #e2ded3;
  --mark-ink: #1f1e1c;
  --badge: #e2ded3;
  --badge-ink: #101010;
  --flash: #fff;
  /* On the stage itself. */
  --edge: rgb(22 21 19 / 0.8);
  --focus: #1b1a18;
  --chip-key: rgb(0 0 0 / 0.05);
  --chip-line: rgb(0 0 0 / 0.14);
  --chip-ink: rgb(0 0 0 / 0.62);
  --u: var(--choices-unit, min(1.5px, calc((100cqi - 32px) / 214)));
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 22px;
  box-sizing: border-box;
  width: 100%;
  padding: 28px 16px;
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(22 21 19 / 0.8), rgb(0 0 0 / 0));
    --focus: light-dark(#1b1a18, #fff);
    --chip-key: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --chip-line: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.16));
    --chip-ink: light-dark(rgb(0 0 0 / 0.62), rgb(255 255 255 / 0.7));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* Room for three pills, so the stage keeps its height from one exchange to the next. */
.stage {
  display: grid;
  place-items: center;
  min-width: 0;
  max-width: 100%;
  min-height: calc(130 * var(--u));
}

.stack {
  position: relative;
  display: grid;
  /* At least 188 units, as wide as the longest label, never wider than the stage: a label that
     cannot fit ends in an ellipsis instead of running out of its pill. */
  grid-template-columns: minmax(calc(188 * var(--u)), max-content);
  max-width: 100%;
  box-sizing: border-box;
  row-gap: calc(7 * var(--u));
  padding: calc(4 * var(--u)) calc(8 * var(--u)) calc(6 * var(--u)) calc(16 * var(--u));
  color: var(--ink);
  font-size: calc(16 * var(--u));
  line-height: 1;
  user-select: none;
  -webkit-user-select: none;
  outline: none;
  transition:
    opacity 200ms ease-in,
    translate 240ms cubic-bezier(0.4, 0, 1, 1);
}
.stack:focus-visible {
  border-radius: calc(12 * var(--u));
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
.stack[data-phase='gone'] {
  opacity: 0;
  translate: calc(14 * var(--u)) 0;
}
.stack[data-phase='gone'] .pill,
.stack[data-phase='gone'] .marker {
  visibility: hidden;
  transition: visibility 0s 240ms;
}

/* ---- Pills ---- */

.pill {
  position: relative;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  height: calc(34 * var(--u));
  padding: 0 calc(22 * var(--u)) 0 calc(12 * var(--u));
  border-radius: calc(17 * var(--u));
  background: var(--pill);
  box-shadow:
    inset 0 0 0 calc(1.5 * var(--u)) var(--pill-line),
    0 0 0 calc(1 * var(--u)) var(--pill-rim);
  min-width: 0;
  white-space: nowrap;
  cursor: pointer;
  animation: arrive 300ms calc(var(--i) * 45ms) cubic-bezier(0.2, 0.9, 0.3, 1.08) backwards;
  transition:
    opacity 180ms ease-in,
    translate 220ms cubic-bezier(0.4, 0, 1, 1);
}
/* The focused look, a layer that fades in: darker, a light outline, a warm glow inside and out. */
.glow {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--pill-on);
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) var(--on-line),
    inset 0 0 calc(4 * var(--u)) calc(2 * var(--u)) var(--on-glow),
    0 0 0 calc(1 * var(--u)) var(--on-halo),
    0 0 calc(4 * var(--u)) calc(1 * var(--u)) var(--on-halo);
  opacity: 0;
  pointer-events: none;
  transition: opacity 160ms ease-out;
}
.pill[data-focused] .glow {
  opacity: 1;
  transition-duration: 110ms;
}
/* A pick: the chosen pill dips and flashes; the others fall away. */
.pill::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--flash);
  opacity: 0;
  pointer-events: none;
}
.pill[data-picked] {
  animation: pick 340ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.pill[data-picked]::after {
  animation: flash 340ms ease-out;
}
.pill[data-other] {
  opacity: 0;
  translate: 0 calc(6 * var(--u));
}

.label {
  position: relative;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  /* Room for the italic's overhang inside the clip. */
  padding-right: 0.12em;
  font-style: italic;
  font-weight: 450;
  letter-spacing: -0.005em;
}

/* ---- The pointer arrow ---- */

.marker {
  position: absolute;
  left: calc(5.4 * var(--u));
  top: calc(10.5 * var(--u));
  width: calc(15 * var(--u));
  height: calc(21 * var(--u));
  translate: 0 calc(var(--at) * 41 * var(--u));
  pointer-events: none;
  animation: markerIn 260ms calc(var(--count) * 45ms + 120ms) cubic-bezier(0.2, 0.9, 0.3, 1.2)
    backwards;
  transition: translate 230ms cubic-bezier(0.3, 1.45, 0.5, 1);
}
.markerArt {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  animation: bob 1100ms ease-in-out infinite;
}
.stack[data-phase='picked'] .markerArt {
  animation: poke 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.markerEdge {
  fill: none;
  stroke: var(--edge);
  stroke-width: 4.4;
  stroke-linejoin: round;
}
.markerLine {
  fill: none;
  stroke: var(--mark);
  stroke-width: 2;
  stroke-linejoin: miter;
}
.markerCore {
  fill: var(--mark);
}

/* ---- The stepper: ◀ n ▶ ---- */

.stepper {
  position: relative;
  display: flex;
  align-items: center;
  margin-left: calc(1 * var(--u));
}
.step {
  display: grid;
  place-items: center;
  /* A bigger target than the drawing. */
  width: calc(7 * var(--u));
  height: calc(34 * var(--u));
  padding: 0 calc(1.5 * var(--u));
  margin: 0 calc(-1.5 * var(--u));
  cursor: pointer;
}
.stepArt {
  display: block;
  width: calc(8 * var(--u));
  height: calc(14 * var(--u));
  overflow: visible;
  transition: opacity 160ms;
}
.step[data-off] .stepArt {
  opacity: 0.35;
}
.step[data-side='left'] .stepArt[data-kick] {
  animation: kickLeft 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.step[data-side='right'] .stepArt[data-kick] {
  animation: kickRight 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.stepShape {
  fill: var(--mark);
}
.stepMark {
  fill: none;
  stroke: var(--mark-ink);
  stroke-width: 1.3;
}
.chip {
  display: grid;
  place-items: center;
  width: calc(46 * var(--u));
  height: calc(22 * var(--u));
  margin: 0 calc(2 * var(--u)) 0 calc(3 * var(--u));
  overflow: hidden;
  border-radius: calc(1.5 * var(--u));
  background: var(--chip);
}
.chip[data-shake] {
  animation: shake 300ms linear;
}
.value {
  display: block;
  font-style: italic;
  font-weight: 450;
  font-variant-numeric: tabular-nums;
}
.value[data-from='right'] {
  animation: fromRight 200ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.value[data-from='left'] {
  animation: fromLeft 200ms cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* ---- The B badge of a cancel answer ---- */

.badge {
  position: absolute;
  right: calc(-5 * var(--u));
  bottom: calc(-4.7 * var(--u));
  display: grid;
  place-items: center;
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  border-radius: 50%;
  background: var(--badge);
  box-shadow: 0 0 0 calc(1 * var(--u)) var(--edge);
  color: var(--badge-ink);
  font-size: calc(10.5 * var(--u));
  font-style: normal;
  font-weight: 800;
  line-height: 1;
}
.badge[data-hit] {
  animation: badgeHit 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* ---- The keys, as small chips under the stack, and the live region ---- */

.legend {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 6px;
  margin: 0;
  color: var(--chip-ink);
  font-size: 12px;
  line-height: 1;
  pointer-events: none;
}
.legend kbd {
  display: inline-block;
  min-width: 1.9em;
  box-sizing: border-box;
  padding: 0.4em 0.45em;
  border: 1px solid var(--chip-line);
  border-radius: 6px;
  background: var(--chip-key);
  font: inherit;
  font-weight: 600;
  text-align: center;
}
.legendGap {
  width: 8px;
}
@container (max-width: 479px) {
  .legend {
    display: none;
  }
}
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes arrive {
  from {
    opacity: 0;
    translate: calc(18 * var(--u)) 0;
  }
}
@keyframes markerIn {
  from {
    opacity: 0;
    translate: calc(-8 * var(--u)) calc(var(--at) * 41 * var(--u));
  }
}
@keyframes bob {
  50% {
    translate: calc(-2.5 * var(--u)) 0;
  }
}
@keyframes poke {
  40% {
    translate: calc(4 * var(--u)) 0;
  }
}
@keyframes pick {
  30% {
    scale: 0.96;
  }
  70% {
    scale: 1.015;
  }
}
@keyframes flash {
  from {
    opacity: 0.5;
  }
}
@keyframes kickLeft {
  35% {
    translate: calc(-3 * var(--u)) 0;
    filter: drop-shadow(0 0 calc(3 * var(--u)) rgb(255 248 225 / 0.9));
  }
}
@keyframes kickRight {
  35% {
    translate: calc(3 * var(--u)) 0;
    filter: drop-shadow(0 0 calc(3 * var(--u)) rgb(255 248 225 / 0.9));
  }
}
@keyframes fromRight {
  from {
    opacity: 0;
    translate: calc(10 * var(--u)) 0;
  }
}
@keyframes fromLeft {
  from {
    opacity: 0;
    translate: calc(-10 * var(--u)) 0;
  }
}
@keyframes shake {
  20%,
  60% {
    translate: calc(-2 * var(--u)) 0;
  }
  40%,
  80% {
    translate: calc(2 * var(--u)) 0;
  }
}
@keyframes badgeHit {
  35% {
    scale: 0.82;
    background: var(--flash);
    box-shadow:
      0 0 0 calc(1 * var(--u)) var(--edge),
      0 0 calc(6 * var(--u)) rgb(255 248 225 / 0.9);
  }
}

@media (prefers-reduced-motion: reduce) {
  .stack,
  .pill,
  .glow,
  .marker,
  .stepArt {
    transition: none;
  }
  .pill,
  .pill[data-picked],
  .marker,
  .markerArt,
  .stack[data-phase='picked'] .markerArt,
  .step .stepArt[data-kick],
  .chip[data-shake],
  .value[data-from],
  .badge[data-hit] {
    animation: none;
  }
  .stack[data-phase='gone'],
  .pill[data-other] {
    translate: none;
  }
}
```

### choice-list/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 };
}
```