Choice list
NewA game’s dialog answers: dark pills with a pointer arrow on the focused one, a quantity stepper and a cancel answer that B picks.
choice-list/choice-list.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
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.
You can add this component with the shadcn CLI or by hand:
Into components/choice-list of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/choice-list.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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
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} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
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.
- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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 | 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 | true | Listen to the keyboard while the stack has focus, and to a connected gamepad; also shows the key chips underneath. |
label | string | 'Choices' | The stack’s accessible name. |
autoplay | boolean | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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:/* 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);// 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);- 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.