Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Choice list

New

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.

choice-list/choice-list.tsx
Live · interactive

The 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/choice-list.json

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

shop.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}    />  );}

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.
PropTypeDefaultDescription
optionsChoiceOption[]—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.
focusednumber—The cursor’s index, controlled.
defaultFocusednumber0The 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.
openboolean—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.
keysbooleantrueListen to the keyboard while the stack has focus, and to a connected gamepad; also shows the key chips underneath.
labelstring'Choices'The stack’s accessible name.
autoplaybooleantrueWhile 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.
classNamestring—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.

choice-list.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:
choice-list.module.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);
use-autoplay.ts
// 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.