# Action prompts

> A game HUD’s context-sensitive button prompts: each context brings its set of verbs and button glyphs, swapped with a stagger.

The corner of a game screen that says what the buttons do right now: Jump X and Let go B on a wall, Throw R while carrying, Sort, Back and Select in a menu. Each verb sits beside its button drawn in the controller’s shape (face disc, shoulder key, trigger, stick, d-pad). Changing context swaps the set prompt by prompt, keeping the ones that still apply; emphasised prompts glow, and pressing one dips its glyph with a ring of light. Each prompt sits on its own dark plate, so it reads on a light page or a dark one. Pick a context from the chips, then press the prompts or their letters.

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

Category: Games · Controls.

## Anatomy

1. **HUD corner**: Room for four prompts, the list pinned to its foot with its right edge a little right of centre, so a set of any size sits about the middle. `bare` leaves it out.
2. **Context chips**: Small neutral chips under the prompts, one per set, to try each context. Drawn only without `bare` and with more than one set.
3. **Current context**: The context shown, filled with the page’s ink and marked `aria-pressed`; any other asks `onContextChange`.
4. **Prompt list**: The set as a list named after its context, lined up on the right; its size comes from `--prompt-size`, or from the component’s width.
5. **Emphasised prompt**: A prompt with `emphasis`: its plate gets a warm rim and a soft light behind it that flares as it arrives, then breathes.
6. **Verb**: What the button does, in italics and on one line; it brightens on hover and flashes when pressed.
7. **Shoulder glyph**: L or R as a key with its outer top corner cut off, sized in em so it scales with the verb.
8. **Face glyph**: A disc with the letter upright and bold: A, B, X, Y, + or −. Its letter on the keyboard presses it too.

## When to use

- A game HUD where the same buttons do different things by context: on a wall, while carrying, in a menu.
- Telling the player which button does what right now, as a verb beside the button drawn in the controller’s shape.
- Swapping one context’s actions for the next without a jump: prompts that still apply stay and glide, the rest leave and the new ones slide in.
- Drawing the eye to a new or time-sensitive action (a charge to detonate, a thing to throw) with `emphasis`.
- A row of controls at the foot of a game menu (Sort, Back, Select), with `layout="row"` and `bare`.

## When not to use

- A list of every control in the game. Use a controls screen or a table of buttons; prompts show only what applies now.
- Keyboard shortcuts in a desktop app or a web tool. Use a shortcut hint with key caps, or the menu item’s own shortcut.
- A single glyph inside a sentence or a tutorial line. Use the controller glyphs on their own.
- An action the player must confirm or read before going on. Use a dialog or a toast with its own buttons.
- Ordinary web buttons dressed as a game. Use buttons; the glyph only means something with a controller in hand.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/action-prompts.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 { ActionPrompts, type ActionSet } from './action-prompts';

const SETS: ActionSet[] = [
  { id: 'roam', label: 'Roam', prompts: [] },
  {
    id: 'climb',
    label: 'Climb',
    prompts: [
      { label: 'Jump', button: 'X' },
      { label: 'Let go', button: 'B' },
    ],
  },
  {
    id: 'carry',
    label: 'Carry',
    prompts: [
      { label: 'Throw', button: 'R', emphasis: true },
      { label: 'Cancel', button: 'B' },
    ],
  },
];

export function ControlsHint({ context }: { context: 'roam' | 'climb' | 'carry' }) {
  return <ActionPrompts bare sets={SETS} context={context} />;
}
```

## States

- **Two prompts**: On a wall: Jump X and Let go B, two face buttons.
- **Emphasis**: Carrying: Throw R glows; the stick’s press arrow rotates.
- **Two emphasised**: A charge placed: Detonate L and Aim Underfoot on the d-pad both glow.
- **Four prompts**: Grabbing: the face cluster’s top and bottom, the right stick and Cancel B.
- **Riding**: A trigger, a shoulder, the left stick pulled down and A.
- **Empty**: A context with no prompts: nothing is drawn but the chips.
- **Row**: `layout="row"`: the menu’s Sort, Back and Select along the bottom, as at the foot of a game menu.
- **Bare**: `bare`: the list alone at `--prompt-size`, without the room around it or the chips, to lay over a game.
- **Light**: `colorScheme="light"`: the plates keep their dark fill, with a soft shadow on the page; the chips are light.
- **Dark**: `colorScheme="dark"`: the plates take a faint light edge so they part from a dark page; the chips turn dark.

## How it works

- Each context is a set of prompts: a verb and a button. A prompt’s id is its label and button unless given, so a prompt present in both the old and the new set (Cancel B, say) is the same row and stays on screen.
- On a swap, before the browser paints, every row is measured. Leavers are taken out of the flow where they stood (pinned to the list’s foot and right edge) and slide 0.6 em right as they fade in 170 ms, 30 ms apart. Rows that stay glide from their old place to their new one by FLIP with the Web Animations API. Newcomers slide 0.9 em in from the right with an overshoot, 55 ms apart from the top, starting 150 ms in so they never cross the leavers, and each glyph pops in from half its size 60 ms after its verb.
- The glyphs are small SVGs drawn in px of a 27 px verb and sized in em, so a prompt scales as one: a 32 px face disc, a 36 × 28 shoulder key with its outer corner cut, a trigger whose top slopes outwards, a d-pad marking its direction with a dark square, the four face buttons as rings with the one meant filled and pointing, and a stick seen slightly from above with a press or a direction arrow cut out of it by a mask.
- An emphasised prompt (an action to notice: a charge to detonate, a thing to throw) gets a warm rim on its plate and a soft box-shadow light behind it, with a faint glow on the verb; it flares once as it arrives, then breathes every 1.8 s. No filter touches the glyphs or the verb, so they stay sharp at any pixel density.
- Pressing a prompt remounts its glyph and verb, which replays their animations: the glyph dips to 0.82 and springs past 1, a ring expands and fades, the verb flashes brighter. A prompt with a next context moves there 220 ms later, so the press is seen before the set leaves.
- Uncontrolled, it keeps its own context, with a row of context chips under the prompts, and a demo plays a short run of play (climb, carry, place a charge, ride, open a menu) while it is on screen and untouched; any input hands it to the reader. Given context, it shows that set and asks onContextChange to move.
- There is no game behind the prompts here, so each sits on its own dark plate with round ends, as a HUD’s banners do, and the glyphs and verbs keep their colours on any page. The plate’s edge, its shadow and the chips follow the colour scheme through light-dark() tokens. The list sits in a room kept for four prompts, pinned to its foot with its right edge a little right of centre, so sets of any size sit about the middle without jumping sideways. bare draws the list alone, sized by --prompt-size.

## API reference

- **sets** (`ActionSet[]`, default `roam, climb, carry, charge, grab, ride, menu`): The contexts, each { id, label, prompts }. A prompt is { label, button, emphasis?, next?, id? }: button is A, B, X, Y, +, -, L, R, ZL, ZR, dpad(-up/-down/-left/-right), face-up/down/left/right, or a stick: ls, rs, with -press, -up or -down.
- **context** (`string`): The context shown, controlled: changing it swaps the set. The demo is off, and the context chips ask onContextChange.
- **defaultContext** (`string`, default `'climb'`): Uncontrolled, the context it starts in.
- **onContextChange** (`(context: string) => void`): Asked to change context: a pressed prompt’s next, or a context chip.
- **onAction** (`(prompt: ActionPrompt, context: string) => void`): Called when a prompt is pressed, by click, tap, Enter or its letter.
- **layout** (`'column' | 'row'`, default `'column'`): A column lined up on the right, as in play, or a row, as at the foot of a menu.
- **bare** (`boolean`, default `false`): The list alone, without the room kept around it and the context chips, to lay over a game of your own. Their type is 27 px, or --prompt-size.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play: contexts change and prompts are pressed. Uncontrolled only.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the plates’ edge and shadow and the chips; by default it follows the page. The glyphs and verbs keep their colours.
- **className** (`string`): Added to the root.

## Accessibility

- Every prompt is a real button named by its verb and button (“Jump, X button”, “Rotate, Right stick, press”); the glyph art is aria-hidden.
- While the component has focus, the letters A, B, X, Y, L and R press the prompt on that button, as a controller would; each prompt says its key with aria-keyshortcuts.
- A polite live region reads a new set (“Climb: Jump, X button. Let go, B button.”) and the verb of a pressed prompt.
- Leaving prompts are hidden from assistive technology and taken out of the tab order at once; if the focus was on one, it moves to the new set’s first prompt, or to the list.
- The context chips are toggle buttons (aria-pressed) in a labelled group; everything shows a :focus-visible ring.
- Reduced motion: no slides, pops, dips, rings or breathing; sets crossfade and the glow is still. The demo never plays.

## Source

### action-prompts/action-prompts.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { Glyph, buttonName, glyphShape, type PromptButton } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './action-prompts.module.css';

/*
 * Action prompts: the corner of a game HUD that says what the buttons do right now. The controls
 * are context-sensitive (the same B is Back in a menu, Let go on a wall, Cancel while aiming), so
 * each context brings its own set: a verb, then the button in the controller's shape.
 *
 * - A set swaps by prompt. Prompts that stay valid in the new context (Cancel B, say) stay and
 *   glide to their new place (measured before and after, then played back from the old place:
 *   FLIP). The others leave: they are taken out of the flow where they stood and slide right as
 *   they fade, 30 ms apart. The newcomers slide in from the right with a little overshoot, 55 ms
 *   apart from the top, each glyph popping in just after its verb.
 * - An emphasised prompt (a new or time-sensitive action: a charge to detonate, a thing to throw)
 *   glows. It flares once as it arrives, then breathes.
 * - Every prompt is a real button. Pressing it (a click, a tap, Enter, or the button's own letter
 *   while the component has focus: A, B, X, Y, L, R) dips the glyph in and springs it back with a
 *   ring of light, brightens the verb, calls `onAction`, and, uncontrolled, follows the prompt's
 *   `next` context a beat later, so the press is seen before the set leaves.
 * - Uncontrolled, it keeps its own context, with a row of context chips under the prompts, and a
 *   demo plays a short run of play while it is on screen and untouched (climb, carry, place a
 *   charge, ride, open a menu). Given `context`, it shows that set; `onContextChange` asks to
 *   change it.
 * - The prompts stand alone, centred: each sits on its own dark plate, as a HUD's banners do, so
 *   it reads on a light page or a dark one. `bare` draws the list alone, without the room kept
 *   for four prompts and without the chips, to lay over a game.
 */

export type { PromptButton };

export type ActionPrompt = {
  /** Stable id within the sets; defaults to the label and button. Same id in two sets = stays. */
  id?: string;
  /** The verb, as shown. */
  label: string;
  /** Which button, drawn in its controller shape. */
  button: PromptButton;
  /** Glows: a new or time-sensitive action the player should notice. */
  emphasis?: boolean;
  /** The context to go to once it is pressed (uncontrolled), or null for none. */
  next?: string | null;
};

export type ActionSet = {
  /** The context's id. */
  id: string;
  /** Its name, for the stage's context buttons and for screen readers. */
  label: string;
  prompts: ActionPrompt[];
};

export const DEFAULT_SETS: ActionSet[] = [
  { id: 'roam', label: 'Roam', prompts: [] },
  {
    id: 'climb',
    label: 'Climb',
    prompts: [
      { label: 'Jump', button: 'X' },
      { label: 'Let go', button: 'B', next: 'roam' },
    ],
  },
  {
    id: 'carry',
    label: 'Carry',
    prompts: [
      { label: 'Throw', button: 'R', emphasis: true, next: 'roam' },
      { label: 'Rotate', button: 'rs-press' },
      { label: 'Cancel', button: 'B', next: 'roam' },
    ],
  },
  {
    id: 'charge',
    label: 'Charge',
    prompts: [
      { label: 'Detonate', button: 'L', emphasis: true, next: 'roam' },
      { label: 'Aim Underfoot', button: 'dpad-down', emphasis: true },
      { label: 'Cancel', button: 'B', next: 'roam' },
    ],
  },
  {
    id: 'grab',
    label: 'Grab',
    prompts: [
      { label: 'Push', button: 'face-up' },
      { label: 'Pull', button: 'face-down' },
      { label: 'Move', button: 'rs' },
      { label: 'Cancel', button: 'B', next: 'roam' },
    ],
  },
  {
    id: 'ride',
    label: 'Ride',
    prompts: [
      { label: 'Reset Camera', button: 'ZL' },
      { label: 'Soothe', button: 'L' },
      { label: 'Woah...', button: 'ls-down' },
      { label: 'Yah!', button: 'A' },
    ],
  },
  {
    id: 'menu',
    label: 'Menu',
    prompts: [
      { label: 'Sort', button: 'Y' },
      { label: 'Back', button: 'B', next: 'roam' },
      { label: 'Select', button: 'A' },
    ],
  },
];

type Phase = 'in' | 'stay' | 'out';
type Row = {
  key: string;
  prompt: ActionPrompt;
  phase: Phase;
  /** Its place in the entering order, for the stagger. */
  order: number;
  /** Where a leaving row stood, in px from the list's top and right. */
  at?: { top: number; right: number; bottom: number };
  /** Which swap it is leaving in. */
  round?: number;
};

const keyOf = (prompt: ActionPrompt) => prompt.id ?? `${prompt.label}|${prompt.button}`;

/** How long leaving rows take, the stagger between them, and the delay before newcomers. */
const LEAVE = 170;
const LEAVE_STAGGER = 30;
/** How long after a press its `next` context comes in. */
const NEXT_AFTER = 220;

/** Keyboard letters that press a prompt's button. */
const KEYS: Record<string, PromptButton[]> = {
  a: ['A'],
  b: ['B'],
  x: ['X'],
  y: ['Y'],
  l: ['L', 'ZL'],
  r: ['R', 'ZR'],
};

const shortcut = (button: PromptButton) =>
  Object.entries(KEYS)
    .find(([, buttons]) => buttons.includes(button))?.[0]
    .toUpperCase();

/** The demo: a short run of play, as steps at ms from its start. */
const DEMO: ({ at: number } & ({ context: string } | { press: string }))[] = [
  { at: 0, context: 'roam' },
  { at: 700, context: 'climb' },
  { at: 2300, press: 'Jump|X' },
  { at: 3500, press: 'Let go|B' },
  { at: 4600, context: 'carry' },
  { at: 6600, press: 'Throw|R' },
  { at: 7800, context: 'charge' },
  { at: 9900, press: 'Detonate|L' },
  { at: 11000, context: 'ride' },
  { at: 12500, press: 'Yah!|A' },
  { at: 13100, press: 'Yah!|A' },
  { at: 14300, press: 'Woah...|ls-down' },
  { at: 15400, context: 'menu' },
  { at: 16800, press: 'Select|A' },
  { at: 17900, press: 'Back|B' },
];
const DEMO_LENGTH = 19400;

export type ActionPromptsProps = {
  className?: string;
  /** The contexts and their prompts. */
  sets?: ActionSet[];
  /** The context shown (controlled). */
  context?: string;
  /** The context it starts in, uncontrolled. */
  defaultContext?: string;
  /** Asked when a prompt with `next` is pressed, or a context chip. */
  onContextChange?: (context: string) => void;
  /** Called when a prompt is pressed, with the prompt and its context. */
  onAction?: (prompt: ActionPrompt, context: string) => void;
  /** A column at the lower right (as in play), or a row (as at the foot of a menu). */
  layout?: 'column' | 'row';
  /** The list alone, without the room kept around it and the context chips. */
  bare?: boolean;
  /** Plays a short run of play while on screen and untouched (uncontrolled only). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

export function ActionPrompts({
  className,
  sets = DEFAULT_SETS,
  context: contextProp,
  defaultContext = 'climb',
  onContextChange,
  onAction,
  layout = 'column',
  bare = false,
  autoplay = true,
  colorScheme,
}: ActionPromptsProps) {
  const controlled = contextProp !== undefined;
  const [ownContext, setOwnContext] = useState(defaultContext);
  const context = controlled ? contextProp : ownContext;
  const set = sets.find((item) => item.id === context);
  const prompts = set?.prompts ?? [];

  const { ref, take, playing } = useAutoplay<HTMLDivElement>();
  const demo = playing && autoplay && !controlled;

  const listRef = useRef<HTMLUListElement>(null);
  const rowRefs = useRef(new Map<string, HTMLLIElement>());
  const before = useRef(new Map<string, DOMRect>());
  const [rows, setRows] = useState<Row[]>(() =>
    prompts.map((prompt, order) => ({ key: keyOf(prompt), prompt, phase: 'stay', order })),
  );
  const [presses, setPresses] = useState<Record<string, number>>({});
  const [said, setSaid] = useState('');
  const timers = useRef<number[]>([]);

  const later = useCallback((ms: number, fn: () => void) => {
    timers.current.push(window.setTimeout(fn, ms));
  }, []);
  useEffect(() => () => timers.current.forEach((id) => window.clearTimeout(id)), []);

  const goTo = useCallback(
    (next: string) => {
      if (!controlled) setOwnContext(next);
      onContextChange?.(next);
    },
    [controlled, onContextChange],
  );

  // A new set: measure the old layout, mark leavers where they stand, and let the rest flow.
  const promptsKey =
    prompts.map(keyOf).join('\n') + '\n' + prompts.map((p) => +!!p.emphasis).join('');
  const lastKey = useRef(promptsKey);
  const rounds = useRef(0);
  const refocus = useRef(false);
  useLayoutEffect(() => {
    if (lastKey.current === promptsKey) return;
    lastKey.current = promptsKey;
    const list = listRef.current;
    const box = list?.getBoundingClientRect();
    before.current.clear();
    const round = ++rounds.current;
    // If the focus is on a prompt that is leaving, it moves to the new set (or the list) after.
    const focused = listRef.current?.querySelector('li:has(:focus)');
    const focusedKey = [...rowRefs.current].find(([, element]) => element === focused)?.[0];
    refocus.current = focusedKey !== undefined && !prompts.some((p) => keyOf(p) === focusedKey);
    const rects = new Map<string, DOMRect>();
    for (const [key, element] of rowRefs.current) rects.set(key, element.getBoundingClientRect());
    const wanted = new Map(prompts.map((prompt) => [keyOf(prompt), prompt]));
    setRows((old) => {
      const leaving: Row[] = [];
      for (const row of old) {
        if (wanted.has(row.key) || row.phase === 'out') {
          if (row.phase === 'out') leaving.push(row);
          continue;
        }
        const rect = rects.get(row.key);
        leaving.push({
          ...row,
          phase: 'out',
          round,
          order: leaving.length,
          at:
            rect && box
              ? {
                  top: rect.top - box.top,
                  right: box.right - rect.right,
                  bottom: box.bottom - rect.bottom,
                }
              : undefined,
        });
      }
      const kept = new Set(old.filter((row) => row.phase !== 'out').map((row) => row.key));
      let entering = 0;
      const fresh = prompts.map((prompt): Row => {
        const key = keyOf(prompt);
        if (kept.has(key)) {
          const rect = rects.get(key);
          if (rect) before.current.set(key, rect);
          return { key, prompt, phase: 'stay', order: 0 };
        }
        return { key, prompt, phase: 'in', order: entering++ };
      });
      return [...fresh, ...leaving];
    });
    // A safety net: leavers drop out on their animation's end, or after this at the latest.
    later(LEAVE + LEAVE_STAGGER * 8 + 240, () =>
      setRows((now) => now.filter((row) => row.phase !== 'out' || (row.round ?? 0) !== round)),
    );
    setSaid(
      prompts.length
        ? `${set?.label ?? context}: ${prompts.map((p) => `${p.label}, ${buttonName(p.button)}`).join('. ')}.`
        : '',
    );
  }, [promptsKey]);

  // Remove leavers once they are gone (each out row schedules its own removal on mount).
  const dropOut = useCallback((key: string) => {
    setRows((now) => now.filter((row) => !(row.key === key && row.phase === 'out')));
  }, []);

  // FLIP: rows that stayed play from where they were to where they are.
  useLayoutEffect(() => {
    if (!before.current.size) return;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    for (const [key, rect] of before.current) {
      const element = rowRefs.current.get(key);
      if (!element || reduced) continue;
      const now = element.getBoundingClientRect();
      const dx = rect.left - now.left;
      const dy = rect.top - now.top;
      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
      element.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], {
        duration: 340,
        easing: 'cubic-bezier(0.2, 0.9, 0.25, 1.05)',
      });
    }
    before.current.clear();
  }, [rows]);

  useEffect(() => {
    if (!refocus.current) return;
    refocus.current = false;
    const next = listRef.current?.querySelector<HTMLButtonElement>(
      'li:not([data-phase="out"]) button',
    );
    (next ?? listRef.current)?.focus({ preventScroll: true });
  }, [rows]);

  const press = useCallback(
    (key: string) => {
      const row = rows.find((item) => item.key === key && item.phase !== 'out');
      if (!row) return;
      setPresses((now) => ({ ...now, [key]: (now[key] ?? 0) + 1 }));
      setSaid(row.prompt.label);
      onAction?.(row.prompt, context);
      const next = row.prompt.next;
      if (next != null) later(NEXT_AFTER, () => goTo(next));
    },
    [rows, onAction, context, later, goTo],
  );

  // The demo, on its own clock: it restarts from the top each time it is handed back.
  const step = useEffectEvent((item: (typeof DEMO)[number]) => {
    if ('context' in item) setOwnContext(item.context);
    else press(item.press);
  });
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (start: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), start + item.at));
      ids.push(window.setTimeout(() => run(0), start + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((id) => window.clearTimeout(id));
  }, [demo]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!controlled) take();
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const buttons = KEYS[event.key.toLowerCase()];
    if (!buttons) return;
    const row = rows.find((item) => item.phase !== 'out' && buttons.includes(item.prompt.button));
    if (!row) return;
    event.preventDefault();
    press(row.key);
  };

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-layout={layout}
      data-bare={bare || undefined}
      data-part="root"
      onPointerDown={controlled ? undefined : take}
      onKeyDown={onKeyDown}
    >
      <div className={styles.hud} data-part="hud">
        <ul
          ref={listRef}
          className={styles.list}
          aria-label={`Actions${set ? `, ${set.label}` : ''}`}
          data-part="prompts"
          tabIndex={-1}
        >
          {rows.map((row) => {
            const count = presses[row.key] ?? 0;
            const out = row.phase === 'out';
            const style = {
              '--i': row.order,
              // A leaver stays where it stood, from the corner the list is pinned to (on the stage it
              // is pinned to the foot of its room; bare, it flows from the top and lines up right).
              ...(out && row.at
                ? bare
                  ? { position: 'absolute', top: row.at.top, right: row.at.right }
                  : { position: 'absolute', bottom: row.at.bottom, right: row.at.right }
                : null),
            } as unknown as CSSProperties;
            return (
              <li
                key={row.key + (out ? ':out' : '')}
                ref={(element) => {
                  if (out) return;
                  if (element) rowRefs.current.set(row.key, element);
                  else rowRefs.current.delete(row.key);
                }}
                className={styles.row}
                data-phase={row.phase}
                data-emphasis={row.prompt.emphasis || undefined}
                style={style}
                aria-hidden={out || undefined}
                onAnimationEnd={(event) => {
                  if (out && event.target === event.currentTarget) dropOut(row.key);
                }}
                data-part="prompt"
              >
                <button
                  type="button"
                  className={styles.prompt}
                  tabIndex={out ? -1 : undefined}
                  disabled={out}
                  aria-label={`${row.prompt.label}, ${buttonName(row.prompt.button)}`}
                  aria-keyshortcuts={shortcut(row.prompt.button)}
                  onClick={() => {
                    if (!controlled) take();
                    press(row.key);
                  }}
                >
                  <span
                    className={styles.label}
                    key={`l${count}`}
                    data-pressed={count > 0 || undefined}
                    data-part="label"
                  >
                    {row.prompt.label}
                  </span>
                  <span
                    className={styles.glyph}
                    data-shape={glyphShape(row.prompt.button)}
                    data-part="glyph"
                  >
                    <span className={styles.dip} key={count} data-pressed={count > 0 || undefined}>
                      <Glyph button={row.prompt.button} />
                    </span>
                    {count > 0 && (
                      <span className={styles.ring} key={`r${count}`} aria-hidden="true" />
                    )}
                  </span>
                </button>
              </li>
            );
          })}
        </ul>
      </div>
      {!bare && sets.length > 1 && (
        <div className={styles.contexts} role="group" aria-label="Context" data-part="contexts">
          {sets.map((item) => (
            <button
              key={item.id}
              type="button"
              className={styles.context}
              aria-pressed={item.id === context}
              onClick={() => {
                take();
                goTo(item.id);
              }}
            >
              {item.label}
            </button>
          ))}
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}
```

### action-prompts/glyphs.tsx

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

import { useId } from 'react';

/*
 * The button glyphs, drawn as small SVGs in px of a 27 px prompt (the CSS sizes each one in em, so
 * they scale with the verb beside it). One light fill (--glyph) and one dark ink (--glyph-ink):
 *
 * - Face buttons (A, B, X, Y, +, −): a 32 px disc with the letter upright and bold.
 * - Shoulders (L, R): a 36 × 28 key with its outer top corner cut off. Triggers (ZL, ZR): a key
 *   whose top edge slopes down towards the outside.
 * - D-pad: a 32 px cross; a direction marks its arm with a dark square at the tip.
 * - Face cluster: the four buttons as rings in a diamond, the one meant filled in.
 * - Sticks: a cap seen slightly from above (a rim, a dark ring and the letter), on a short neck
 *   and foot. "press" puts an arrow above, pointing in; "up" / "down" put one at its lower right.
 *   Arrows are cut out of the stick with a mask, so a thin gap of the plate shows around them.
 */

export type FaceButton = 'A' | 'B' | 'X' | 'Y' | '+' | '-';
export type ShoulderButton = 'L' | 'R' | 'ZL' | 'ZR';
export type DpadButton = 'dpad' | 'dpad-up' | 'dpad-down' | 'dpad-left' | 'dpad-right';
export type ClusterButton = 'face-up' | 'face-down' | 'face-left' | 'face-right';
export type StickButton =
  'ls' | 'rs' | 'ls-press' | 'rs-press' | 'ls-up' | 'rs-up' | 'ls-down' | 'rs-down';
export type PromptButton = FaceButton | ShoulderButton | DpadButton | ClusterButton | StickButton;

/** What a screen reader says for each button. */
export function buttonName(button: PromptButton) {
  if (button === '+') return 'Plus button';
  if (button === '-') return 'Minus button';
  if (/^[ABXY]$/.test(button)) return `${button} button`;
  if (button === 'L' || button === 'R' || button === 'ZL' || button === 'ZR')
    return `${button} button`;
  if (button === 'dpad') return 'D-pad';
  if (button.startsWith('dpad-')) return `D-pad ${button.slice(5)}`;
  if (button.startsWith('face-')) return `${button.slice(5)} face button`;
  const stick = button.startsWith('ls') ? 'Left stick' : 'Right stick';
  if (button.endsWith('-press')) return `${stick}, press`;
  if (button.endsWith('-up')) return `${stick} up`;
  if (button.endsWith('-down')) return `${stick} down`;
  return stick;
}

/** The glyph's shape family, for its size in CSS. */
export function glyphShape(button: PromptButton) {
  if (/^[ABXY+-]$/.test(button)) return 'face';
  if (button === 'L' || button === 'R') return 'shoulder';
  if (button === 'ZL' || button === 'ZR') return 'trigger';
  if (button.startsWith('dpad')) return 'dpad';
  if (button.startsWith('face-')) return 'cluster';
  if (button.endsWith('-press')) return 'stick-press';
  if (button.endsWith('-up') || button.endsWith('-down')) return 'stick-arrow';
  return 'stick';
}

const LETTER = { fontWeight: 600, fontStyle: 'normal' } as const;
const STICK_LETTER = { fontWeight: 500, fontStyle: 'normal' } as const;

function Face({ button }: { button: FaceButton }) {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="var(--glyph)" />
      {button === '+' || button === '-' ? (
        <path
          d={button === '+' ? 'M16 8.5v15M8.5 16h15' : 'M8.5 16h15'}
          stroke="var(--glyph-ink)"
          strokeWidth="3.4"
        />
      ) : (
        <text
          x="16"
          y="22.8"
          textAnchor="middle"
          fontSize="19"
          fill="var(--glyph-ink)"
          style={LETTER}
        >
          {button}
        </text>
      )}
    </svg>
  );
}

function Shoulder({ button }: { button: ShoulderButton }) {
  const trigger = button.startsWith('Z');
  const right = button.endsWith('R');
  // Drawn for the left hand, mirrored for the right; the letters are not mirrored.
  const shape = trigger
    ? 'M2 8.2 L32 1.3 L32 27 L2 27 Z'
    : 'M8.6 1.3 L34.6 1.3 L34.6 26.6 L1.4 26.6 Z';
  const width = trigger ? 34 : 36;
  const height = trigger ? 28.5 : 28;
  return (
    <svg viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
      <path
        d={shape}
        fill="var(--glyph)"
        stroke="var(--glyph)"
        strokeWidth="2.4"
        strokeLinejoin="round"
        transform={right ? `translate(${width} 0) scale(-1 1)` : undefined}
      />
      <text
        x={trigger ? 17 : right ? 16.5 : 19.5}
        y={trigger ? 21.8 : 20.4}
        textAnchor="middle"
        fontSize={trigger ? 16.5 : 17}
        fill="var(--glyph-ink)"
        style={LETTER}
      >
        {button}
      </text>
    </svg>
  );
}

function Dpad({ button }: { button: DpadButton }) {
  const marks: Record<string, string> = {
    'dpad-up': 'M12.5 2.6h7v7h-7z',
    'dpad-down': 'M12.5 22.4h7v7h-7z',
    'dpad-left': 'M2.6 12.5h7v7h-7z',
    'dpad-right': 'M22.4 12.5h7v7h-7z',
  };
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      <path
        d="M10.4 1.5h11.2v8.9h8.9v11.2h-8.9v8.9H10.4v-8.9H1.5V10.4h8.9z"
        fill="var(--glyph)"
        stroke="var(--glyph)"
        strokeWidth="2"
        strokeLinejoin="round"
      />
      {marks[button] && <path d={marks[button]} fill="var(--glyph-ink)" />}
    </svg>
  );
}

function Cluster({ button }: { button: ClusterButton }) {
  const spots = {
    'face-up': [14.5, 4.6],
    'face-left': [4.6, 14.5],
    'face-right': [24.4, 14.5],
    'face-down': [14.5, 24.4],
  } as const;
  return (
    <svg viewBox="0 0 29 29" aria-hidden="true">
      {Object.entries(spots).map(([key, [cx, cy]]) =>
        key === button ? (
          <g key={key}>
            <circle cx={cx} cy={cy} r="4.4" fill="var(--glyph)" />
            {/* A small arrow pointing the way the button faces. */}
            <path
              d="M0 -2.1 L2.2 1.3 L-2.2 1.3 Z"
              fill="var(--glyph-ink)"
              transform={`translate(${cx} ${cy}) rotate(${TURN[key as ClusterButton]})`}
            />
          </g>
        ) : (
          <circle
            key={key}
            cx={cx}
            cy={cy}
            r="3.5"
            fill="none"
            stroke="var(--glyph)"
            strokeWidth="1.8"
          />
        ),
      )}
    </svg>
  );
}

const TURN: Record<ClusterButton, number> = {
  'face-up': 0,
  'face-right': 90,
  'face-down': 180,
  'face-left': 270,
};

/** The stick itself, 36 × 34, with its top-left at (x, y). */
function StickBody({ letter, x, y }: { letter: string; x: number; y: number }) {
  return (
    <g transform={`translate(${x} ${y})`}>
      <ellipse cx="17.6" cy="31.2" rx="6" ry="2.6" fill="var(--glyph)" />
      <path d="M12 26h11.4l-1 4.6h-9.4z" fill="var(--glyph)" />
      <ellipse cx="18" cy="16.8" rx="16.8" ry="11.6" fill="var(--glyph)" />
      <ellipse cx="18" cy="13.2" rx="18" ry="12.6" fill="var(--glyph)" />
      <ellipse
        cx="18"
        cy="13.6"
        rx="15.2"
        ry="10.6"
        fill="none"
        stroke="var(--glyph-ink)"
        strokeWidth="1.5"
      />
      <text
        x="18"
        y="19.6"
        textAnchor="middle"
        fontSize="16"
        fill="var(--glyph-ink)"
        style={LETTER}
      >
        {letter}
      </text>
    </g>
  );
}

function Stick({ button }: { button: StickButton }) {
  const id = useId();
  const letter = button.startsWith('ls') ? 'L' : 'R';
  const press = button.endsWith('-press');
  const up = button.endsWith('-up');
  const arrow = press || up || button.endsWith('-down');
  // Arrow paths: "press" above the cap pointing down; "up"/"down" at the lower right.
  const arrowPath = press
    ? 'M15 0.6h6v6.4h5l-8 7.4-8-7.4h5z'
    : up
      ? 'M33 23.6l6.4 6.4h-4v5.4h-4.8V30h-4z'
      : 'M30.6 23.4h4.8v5.4h4l-6.4 6.6-6.4-6.6h4z';
  const width = press ? 36 : arrow ? 40 : 36;
  const height = press ? 42 : arrow ? 36 : 34;
  const offset = press ? 8 : 0;
  return (
    <svg viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
      {arrow && (
        <defs>
          <mask id={id} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
            <rect width={width} height={height} fill="#fff" />
            <path
              d={arrowPath}
              fill="#000"
              stroke="#000"
              strokeWidth="3.2"
              strokeLinejoin="round"
            />
          </mask>
        </defs>
      )}
      <g mask={arrow ? `url(#${id})` : undefined}>
        <StickBody letter={letter} x={0} y={offset} />
      </g>
      {arrow && (
        <path
          d={arrowPath}
          fill="var(--glyph)"
          stroke="var(--glyph)"
          strokeWidth="0.8"
          strokeLinejoin="round"
        />
      )}
    </svg>
  );
}

export function Glyph({ button }: { button: PromptButton }) {
  const shape = glyphShape(button);
  if (shape === 'face') return <Face button={button as FaceButton} />;
  if (shape === 'shoulder' || shape === 'trigger')
    return <Shoulder button={button as ShoulderButton} />;
  if (shape === 'dpad') return <Dpad button={button as DpadButton} />;
  if (shape === 'cluster') return <Cluster button={button as ClusterButton} />;
  return <Stick button={button as StickButton} />;
}
```

### action-prompts/action-prompts.module.css

```css
/* Action prompts (action-prompts.tsx): the HUD's prompts on their own, centred on a plain stage,
   with a row of context chips below. Every size of a prompt is in em of its verb (27 px at full
   size: a 32 px face button, a 36 × 28 shoulder key), so the stage can draw them smaller on a
   narrow screen (a container query), and a bare list takes its size from --prompt-size.

   A game draws its prompts straight over the world; here there is no world, so each prompt sits on
   its own dark plate, as a HUD's banners do, and reads the same on a light or a dark page. The
   glyphs and the verb keep their colours on both; only the plate's edge, the shadow and the chips
   follow the colour scheme, through light-dark() tokens. No filter touches a shape: the shadows
   and the emphasis glow are box- and text-shadows, layers drawn behind the sharp art.

   Motion: newcomers and leavers are keyframes on their row (staggered by --i), the glyph's pop,
   the press's dip and ring, and the emphasis's flare and breath are keyframes on their parts;
   rows that stay glide by a FLIP the script plays with the Web Animations API. */

.root {
  /* The prompts: the verb, the glyph and its ink, the emphasis glow. */
  --text: #e0ded1;
  --text-hot: #f6f3e6;
  --glyph: #e1ded1;
  --glyph-ink: #24221d;
  --glow: rgb(255 246 214 / 0.75);
  --glow-wide: rgb(255 240 200 / 0.38);
  /* The plate behind each prompt, its edge and its shadow on the page. */
  --plate: rgb(26 25 22 / 0.9);
  --plate-hot: rgb(40 37 30 / 0.94);
  --plate-edge: rgb(255 255 255 / 0.07);
  --shade: rgb(20 18 12 / 0.22);
  /* The context chips. */
  --chip: #f2f1ee;
  --chip-hover: #e8e7e3;
  --chip-text: #4a4944;
  --chip-edge: rgb(0 0 0 / 0.08);
  --chip-on: #1f1e1b;
  --chip-on-text: #f4f3ef;
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 28px;
  width: 100%;
  min-height: 320px;
  padding: 28px 16px;
  box-sizing: border-box;
  color: var(--text);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate: light-dark(rgb(26 25 22 / 0.9), rgb(14 14 12 / 0.82));
    --plate-edge: light-dark(rgb(255 255 255 / 0.07), rgb(255 255 255 / 0.13));
    --shade: light-dark(rgb(20 18 12 / 0.22), rgb(0 0 0 / 0.45));
    --chip: light-dark(#f2f1ee, #26262a);
    --chip-hover: light-dark(#e8e7e3, #303035);
    --chip-text: light-dark(#4a4944, #c9c8c3);
    --chip-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --chip-on: light-dark(#1f1e1b, #ecebe6);
    --chip-on-text: light-dark(#f4f3ef, #1c1b18);
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
/* 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;
}

.root[data-bare] {
  display: block;
  min-height: 0;
  padding: 0;
  container-type: normal;
}

/* The HUD's corner: room for four prompts, the list pinned to its foot, its right edge a little
   right of centre so a set of any width sits about the middle and never jumps sideways. */
.hud {
  position: relative;
  width: 100%;
  height: 7.9em;
  font-size: var(--prompt-size, clamp(20px, 4.6cqi, 30px));
}
.root[data-layout='row'] .hud {
  height: 3.6em;
}
.root[data-bare] .hud {
  display: contents;
}

/* The list. On the stage it is pinned to the HUD's foot; bare, it flows and lines up right. */
.list {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.36em;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--prompt-size, 27px);
  line-height: 1;
}
/* It takes the focus only when the prompt that had it leaves (and nothing replaces it). */
.list:focus {
  outline: none;
}
.root[data-layout='row'] .list {
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: center;
  column-gap: 0.5em;
  row-gap: 0.4em;
}
.root:not([data-bare]) .list {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  padding-inline-end: max(0px, 50% - 4.4em);
  font-size: 1em;
}
.root:not([data-bare])[data-layout='row'] .list {
  justify-content: center;
  padding-inline: 0;
}

.row {
  display: flex;
}
.row[data-phase='in'] {
  animation: enter 320ms cubic-bezier(0.2, 0.9, 0.25, 1.18) calc(150ms + var(--i) * 55ms) both;
}
.row[data-phase='out'] {
  pointer-events: none;
  animation: leave 170ms cubic-bezier(0.4, 0, 1, 1) calc(var(--i) * 30ms) forwards;
}
@keyframes enter {
  from {
    opacity: 0;
    translate: 0.9em 0;
  }
  45% {
    opacity: 1;
  }
}
@keyframes leave {
  to {
    opacity: 0;
    translate: 0.6em 0;
  }
}

/* A prompt: the verb, then the glyph, on a dark plate with round ends. */
.prompt {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  margin: 0;
  padding: 0.21em 0.34em 0.21em 0.62em;
  border: 0;
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    0 0.06em 0.28em var(--shade);
  color: var(--text);
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
  transition: background-color 150ms;
}
.prompt:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.label {
  font-style: italic;
  font-size: 0.86em;
  font-weight: 500;
  letter-spacing: -0.02em;
  white-space: nowrap;
}
.label[data-pressed] {
  animation: flash 360ms ease-out;
}
@keyframes flash {
  25% {
    color: var(--text-hot);
    text-shadow: 0 0 0.3em var(--glow);
  }
}
@media (hover: hover) {
  .prompt:hover {
    background: var(--plate-hot);
  }
  .prompt:hover .label {
    color: var(--text-hot);
  }
}

/* The glyph, sized by its shape (px of a 27 px verb, in em). */
.glyph {
  position: relative;
  display: grid;
  flex: none;
  width: 1.185em;
  height: 1.185em;
}
.glyph[data-shape='shoulder'] {
  width: 1.333em;
  height: 1.037em;
}
.glyph[data-shape='trigger'] {
  width: 1.259em;
  height: 1.056em;
}
.glyph[data-shape='cluster'] {
  width: 1.1em;
  height: 1.1em;
}
/* A stick's cap is its top: it sits a little low so the cap, not the foot, meets the verb. */
.glyph[data-shape='stick'] {
  translate: 0 0.1em;
}
.glyph[data-shape='stick-arrow'] {
  translate: 0 0.15em;
}
.glyph[data-shape='stick'] {
  width: 1.333em;
  height: 1.259em;
}
.glyph[data-shape='stick-arrow'] {
  width: 1.481em;
  height: 1.333em;
}
.glyph[data-shape='stick-press'] {
  width: 1.333em;
  height: 1.556em;
  margin-block: -0.2em;
}
.dip {
  display: grid;
}
.dip svg {
  width: 100%;
  height: 100%;
  overflow: visible;
}
.row[data-phase='in'] .glyph {
  animation: pop 300ms cubic-bezier(0.3, 1.6, 0.5, 1) calc(210ms + var(--i) * 55ms) both;
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.5;
  }
}
.dip[data-pressed] {
  animation: dip 280ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
@keyframes dip {
  30% {
    scale: 0.82;
  }
  68% {
    scale: 1.07;
  }
}
.ring {
  position: absolute;
  inset: -0.04em;
  border: 0.08em solid var(--text-hot);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  animation: ring 420ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.glyph:not([data-shape='face']) .ring {
  border-radius: 28%;
}
@keyframes ring {
  from {
    opacity: 0.9;
    scale: 0.85;
  }
  to {
    opacity: 0;
    scale: 1.65;
  }
}

/* Emphasis: a warm rim and a soft light around the plate, behind it, and a glowing verb. It
   flares as it arrives, then breathes. */
.row[data-emphasis] .prompt {
  background: var(--plate-hot);
  box-shadow:
    inset 0 0 0 1.5px var(--glow),
    0 0 0.5em var(--glow-wide),
    0 0.06em 0.28em var(--shade);
  animation:
    flare 700ms ease-out calc(210ms + var(--i) * 55ms) backwards,
    breathe 1800ms ease-in-out calc(910ms + var(--i) * 55ms) infinite alternate;
}
.row[data-emphasis] .label {
  color: var(--text-hot);
  text-shadow: 0 0 0.35em var(--glow-wide);
}
@keyframes flare {
  from {
    box-shadow:
      inset 0 0 0 2px var(--text-hot),
      0 0 1.1em var(--glow),
      0 0.06em 0.28em var(--shade);
  }
}
@keyframes breathe {
  to {
    box-shadow:
      inset 0 0 0 1.5px var(--glow-wide),
      0 0 0.25em var(--glow-wide),
      0 0.06em 0.28em var(--shade);
  }
}

/* The contexts: small neutral chips under the prompts, to try each context. */
.contexts {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  max-width: 560px;
}
.context {
  min-height: 30px;
  padding: 0 12px;
  border: 1px solid var(--chip-edge);
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-text);
  font: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 150ms,
    color 150ms,
    scale 150ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.context:hover {
  background: var(--chip-hover);
}
.context[aria-pressed='true'] {
  border-color: transparent;
  background: var(--chip-on);
  color: var(--chip-on-text);
}
.context:active {
  scale: 0.95;
}
.context:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
@container (max-width: 479px) {
  .contexts {
    gap: 5px;
  }
  .context {
    min-height: 28px;
    padding: 0 10px;
    font-size: 12px;
  }
}

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

/* Reduced motion: no slides, pops or breaths; sets crossfade and a press only brightens. */
@media (prefers-reduced-motion: reduce) {
  .row[data-phase='in'] {
    animation: fade-in 140ms ease-out both;
  }
  .row[data-phase='out'] {
    animation: fade-out 120ms ease-in forwards;
  }
  .row[data-phase='in'] .glyph,
  .dip[data-pressed],
  .ring,
  .row[data-emphasis] .prompt {
    animation: none;
  }
  .context {
    transition: none;
  }
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
```

### action-prompts/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.
 */
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, playing: visible && !reduced && !taken };
}
```