# Button hints

> A game HUD’s map of what each button does now: a clover of face buttons that light up with a verb, shoulder hints and a stick combo.

The cluster a game keeps in a corner so the player never has to look down: the four face buttons in their places on the controller, each one lit with a black banner and its verb when it has something to do, dim when it has not. Shoulder hints sit on dark plates above, and a stick combo pairs a tilt with a button. Each piece carries its own dark ground, so the HUD stands alone and reads on a light or a dark page. Hints swell in and wipe out as the context changes, and every real press (keys, taps or a gamepad) lands with a dip, a flash and a spreading ring.

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

Category: Games · Controls.

## Anatomy

1. **Lit ring**: A face button with an action: a heavy black ring that swells in from the dim circle with an overshoot, a thin gap cut round it in the clover.
2. **Disc**: The light disc with the button’s letter, inside the ring. It flashes white while the button is held.
3. **Banner**: The black band with the verb in italics, growing out of the button away from the clover: right for A, left for Y, up for X, down for B.
4. **Dim button**: A button with nothing to do: a dark circle with a grey letter, so the clover keeps its shape.
5. **Shoulder hint**: A keycap on a dark plate with a short banner that fades out, on its own side (L and ZL left, R and ZR right).
6. **Stick combo**: A stick bubble with an arrow for the way to tilt it, and a plus: the face button it goes with shows the combo’s verb.
7. **Key chips**: The keyboard keys behind the buttons, in small neutral chips under the HUD that follow the light or dark scheme: only with `keys` on, and hidden below 480 px.

## When to use

- A game HUD that tells the player what each button does right now, in the place the button sits on the controller.
- Actions that come and go with the context: a chest in reach, an object in hand, a ledge to vault.
- A tutorial or onboarding screen that shows the controls and answers each real press with a flash.
- A web or prototype game played with a keyboard, a touch screen or a gamepad, where the same hints serve all three.
- A stick-plus-button move worth spelling out next to the button it uses.

## When not to use

- A full list of every control, read before playing. Use a controls screen: a table of buttons and actions.
- Keyboard shortcuts in an app or a website. Use a shortcuts panel or a `kbd` hint next to the command.
- A single prompt tied to a thing in the world ("Press A to talk"). Use an action prompt that floats over that thing.
- Rebinding controls. Use a settings list with one row per action and a button to capture the new key.
- A layout with more than four face actions at once, or with other buttons than the clover. Use a radial or quick selector.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/button-hints.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 { ButtonHints, type HintButton } from './button-hints';

export function Controls({ nearDoor, held }: { nearDoor: boolean; held: HintButton[] }) {
  return (
    <ButtonHints
      actions={{ x: 'Jump', b: 'Dash', a: nearDoor ? 'Open' : undefined }}
      shoulders={{ zl: 'Lock on', zr: 'Aim' }}
      combo={null}
      pressed={held}
      onPress={(button) => console.log(button, 'pressed')}
    />
  );
}
```

## States

- **Exploring**: X Jump and B Dash lit, Y and A dim; ZL Lock on and ZR Aim above.
- **Action in reach**: A thing to use comes into reach: A lights up with Open; nothing else moves.
- **Carrying**: The hints follow the context: Y drops, R throws, A and B go dim.
- **Every hint**: All four face buttons and all four shoulders with a verb.
- **No actions**: `actions={{}}` and `shoulders={{}}`: a dim clover, no banners, no shoulder hints.
- **Stick combo**: `combo={{ direction: 'up', button: 'b', label: 'Vault' }}`: the bubble and its plus, and B carries Vault.
- **Face button held**: `pressed={['a']}`: the ring dips, the disc flashes white and a light ring spreads from it.
- **Shoulder held**: `pressed={['zr']}`: the keycap sinks and turns white.
- **Stick tilted**: `pressed={['up']}` with the combo: the stick leans the way it is tilted.
- **Light**: `colorScheme="light"`: the HUD keeps its own dark grounds; the key chips and the press ring turn dark.
- **Dark**: `colorScheme="dark"`: the same HUD, a faint light edge round its black rings, light key chips and press ring.

## 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(1px, (100cqi − 32px) / 664), or --hints-unit when set), so the whole HUD scales as one drawing and keeps the kit’s proportions: a 70-unit ring round a 42-unit disc, 60-unit dim buttons 39 units out from the clover’s centre, 38-unit banners and 26-to-28-unit type.
- The dim clover is one SVG of four circles under a mask. Each lit button adds a black circle to the mask, scaled in with the button, so a thin gap opens round its ring the way the kit cuts it, showing whatever is behind the HUD.
- Lighting a button scales its ring from 0.857 (the dim circle’s size) to 1 with an overshoot, while its banner opens by clip-path from the button outwards (sideways from the middle for X and B, leftwards for Y, rightwards for A) 70 ms later, and the verb slides in from the button’s side after 150 ms. Going dark runs it all back in about 160 ms. A new verb on a button that stays lit is a remounted word that only fades in.
- Presses come from keys (A, B, X, Y; L and R; Shift+L and Shift+R for ZL and ZR; the arrows for the stick) while the component has focus, from pointers on each hint, from a gamepad polled each frame once one has connected, and from the pressed prop. Each source is tracked by its own id, so a key and a tap on the same button do not release each other.
- A held face button dips to 0.88 in 60 ms and springs back on release; its disc flashes white with a glow, and each press remounts a ring that spreads to 1.5 times and fades, and a flash on the banner’s word. A shoulder keycap sinks 2.5 units and whitens; the stick’s cap leans the way it is tilted and its arrow glows.
- Uncontrolled, a demo plays a short run of play while it is on screen and untouched: walking (Jump, Dash), a chest (A Open, pressed), carrying (Y Drop, R Throw, pressed), a ledge (tilt up + B Vault, pressed together) and an ability on L. Any real input hands it to the reader and brings back the full set of hints.
- The hints stand alone, with no scene behind them: like a game HUD, every piece brings its own dark ground (black rings and banners, dark plates and stick bubble), so it keeps its colours on a light page and a dark one. What sits straight on the page follows the scheme through light-dark() tokens: the press ring, the focus outline, the key chips underneath, and a faint light edge that keeps a black ring round on a dark page. All shapes are SVG and CSS, with no blur filters; glows are only shadows behind sharp shapes.

## API reference

- **actions** (`Partial<Record<FaceButton, string>>`): The face buttons’ verbs, controlled: a button with a verb is lit, with its banner; one without stays dim. Turns the demo off.
- **shoulders** (`Partial<Record<ShoulderButton, string>>`): The shoulder buttons’ verbs (l, zl, r, zr), controlled: a shoulder hint shows while it has one.
- **combo** (`ComboHint | null`): A stick and face button pair, { direction, button, label }: shows the stick bubble and a plus, and puts the label on that button’s banner. null for none.
- **defaultActions** (`Partial<Record<FaceButton, string>>`, default `{ x: Jump, a: Open, b: Dash }`): Uncontrolled, the face verbs shown when the demo is not playing.
- **defaultShoulders** (`Partial<Record<ShoulderButton, string>>`, default `{ l: Ability, zl: Lock on, r: Throw, zr: Aim }`): Uncontrolled, the shoulder verbs shown when the demo is not playing.
- **defaultCombo** (`ComboHint | null`, default `null`): Uncontrolled, the combo shown when the demo is not playing.
- **pressed** (`HintButton[]`): Buttons held down, from a game’s own input: each animates as pressed while it is listed (face and shoulder buttons, and up, down, left, right for the stick).
- **onPress** (`(button: HintButton) => void`): Called when a button goes down, from a key, a tap or a gamepad.
- **keys** (`boolean`, default `true`): Listen to the keyboard while the component has focus, and to a connected gamepad; also shows the key chips under the HUD.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play: hints coming and going with the context, presses landing. Any input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The HUD keeps its colours on both; the press ring, focus and key chips follow it. Set --hints-unit on the root to fix the size (1px is the kit’s 664 × 270).
- **className** (`string`): Added to the root.

## Accessibility

- The component is a focusable group named Button hints; while it has focus, the controller’s keys are listed in aria-keyshortcuts and pressing them plays the hints, without stealing keys anywhere else on the page.
- Each face button is a real button named by its letter and verb (“A: Open”, or “Y, no action”), and each shown shoulder hint and the stick combo are buttons too; hidden hints leave the tab order and the accessibility tree.
- A polite live region reads the hints whenever the context changes (“X: Jump, A: Open, B: Dash, ZL: Lock on”); presses are not announced, so play does not flood it.
- Focus is an outline round the focused hint, and round the whole component when it has focus, dark on a light page and white on a dark one.
- Under reduced motion the demo does not play and every change is instant: lit buttons and banners appear without scaling or wiping, and presses show as a flash without movement.

## Source

### button-hints/button-hints.tsx

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

'use client';

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

/*
 * Button hints: the game HUD's map of what each button does right now. The four face buttons sit
 * in a clover as they do on the controller (X top, Y left, A right, B bottom), so the player reads
 * a position, not a letter.
 *
 * - A face button with an action is lit: a light disc in a heavy black ring, with a black banner
 *   that grows out of it, away from the clover (up for X, left for Y, right for A, down for B),
 *   carrying the verb. A button with nothing to do stays a dim circle, so the clover keeps its
 *   shape. Around a lit ring, the dim clover is cut away by a thin gap (an SVG mask).
 * - Lighting up: the dim circle swells into the ring with an overshoot, the banner wipes out from
 *   the button and the verb slides in after it. Going dark runs it back, faster. A new verb on a lit
 *   button only fades the word in.
 * - Shoulder hints (L and ZL on the left, R and ZR on the right) are a keycap on a dark plate with
 *   a short banner that fades out; they slide in from their side when they have a verb.
 * - A combo hint is a stick bubble, with an arrow for the way to tilt it, and a plus: the face
 *   button it goes with shows the combo's verb.
 * - Presses: the real keys (A, B, X, Y; L and R; Shift+L and Shift+R for ZL and ZR; the arrows
 *   for the stick) while the component has focus, a tap or a click on a button, or a gamepad once
 *   one is connected. A pressed button dips and springs back, its disc flashes and a light ring
 *   spreads from it; a keycap sinks; the stick leans the way it is tilted.
 * - Uncontrolled, a demo plays a short run of play (hints coming and going with the context,
 *   presses landing) while it is on screen and untouched; any input hands it to the reader. Given
 *   `actions`, `shoulders` or `combo`, it shows those, and `pressed` drives the presses from a game.
 * - The hints stand alone on the page's stage, light or dark. Each piece carries its own dark ground
 *   (black rings and banners, dark plates and bubble), as a game HUD does, so it reads on both; the
 *   keyboard keys sit in small neutral chips underneath. `--hints-unit` fixes the size.
 */

export type FaceButton = 'a' | 'b' | 'x' | 'y';
export type ShoulderButton = 'l' | 'zl' | 'r' | 'zr';
export type StickDirection = 'up' | 'down' | 'left' | 'right';
export type HintButton = FaceButton | ShoulderButton | StickDirection;
export type FaceActions = Partial<Record<FaceButton, string>>;
export type ShoulderActions = Partial<Record<ShoulderButton, string>>;
export type ComboHint = {
  /** The way to tilt the stick. */
  direction: StickDirection;
  /** The face button to press with it; its banner shows the combo's verb. */
  button: FaceButton;
  /** The combo's verb. */
  label: string;
};

const FACES: FaceButton[] = ['x', 'y', 'a', 'b'];
const SHOULDERS: ShoulderButton[] = ['l', 'zl', 'r', 'zr'];
const DIRECTIONS: StickDirection[] = ['up', 'right', 'down', 'left'];
/** Where each face button sits, as a unit step from the clover's centre. */
const STEP: Record<FaceButton, [number, number]> = {
  x: [0, -1],
  y: [-1, 0],
  a: [1, 0],
  b: [0, 1],
};
/** The face buttons' distance from the clover's centre, in design units (px at full size). */
const SPREAD = 39;
const ARROW_TURN: Record<StickDirection, number> = { up: 0, right: 90, down: 180, left: 270 };

type Context = { actions: FaceActions; shoulders: ShoulderActions; combo: ComboHint | null };

const DEFAULT_ACTIONS: FaceActions = { x: 'Jump', a: 'Open', b: 'Dash' };
const DEFAULT_SHOULDERS: ShoulderActions = {
  l: 'Ability',
  zl: 'Lock on',
  r: 'Throw',
  zr: 'Aim',
};

/** The demo's contexts, by name, and its run: contexts and presses at ms from its start. */
const EXPLORE: Context = {
  actions: { x: 'Jump', b: 'Dash' },
  shoulders: { zl: 'Lock on', zr: 'Aim' },
  combo: null,
};
const NEAR_CHEST: Context = { ...EXPLORE, actions: { ...EXPLORE.actions, a: 'Open' } };
const CARRYING: Context = {
  actions: { x: 'Jump', y: 'Drop' },
  shoulders: { zl: 'Lock on', r: 'Throw' },
  combo: null,
};
const AT_LEDGE: Context = {
  actions: { x: 'Jump', a: 'Grab' },
  shoulders: { zl: 'Lock on', zr: 'Aim' },
  combo: { direction: 'up', button: 'b', label: 'Vault' },
};
const ABILITY: Context = { ...EXPLORE, shoulders: { ...EXPLORE.shoulders, l: 'Ability' } };
type DemoStep = { at: number; context?: Context; press?: HintButton[]; hold?: number };
const DEMO: DemoStep[] = [
  { at: 0, context: EXPLORE },
  { at: 1000, press: ['x'] },
  { at: 2000, context: NEAR_CHEST },
  { at: 3300, press: ['a'] },
  { at: 3800, context: EXPLORE },
  { at: 4700, context: CARRYING },
  { at: 6000, press: ['r'] },
  { at: 6400, context: EXPLORE },
  { at: 7300, context: AT_LEDGE },
  { at: 8500, press: ['up'], hold: 700 },
  { at: 8800, press: ['b'] },
  { at: 9800, context: EXPLORE },
  { at: 10700, context: ABILITY },
  { at: 11800, press: ['l'] },
  { at: 12600, context: EXPLORE },
];
const DEMO_LENGTH = 13600;
/** The keyboard keys behind the buttons, in groups, for the chips under the HUD. */
const LEGEND = [['A', 'B', 'X', 'Y'], ['L', 'R'], ['⇧L', '⇧R'], ['↑']];
/** How long a tap or a demo press stays down, in ms. */
const TAP = 140;

/** Keys to buttons: A B X Y, L R, Shift+L / Shift+R for ZL / ZR, the arrows for the stick. */
function keyButton(event: KeyboardEvent | React.KeyboardEvent): HintButton | null {
  if (event.metaKey || event.ctrlKey || event.altKey) return null;
  const key = event.key.toLowerCase();
  if (key === 'a' || key === 'b' || key === 'x' || key === 'y') return event.shiftKey ? null : key;
  if (key === 'l') return event.shiftKey ? 'zl' : 'l';
  if (key === 'r') return event.shiftKey ? 'zr' : 'r';
  if (event.key.startsWith('Arrow')) return event.key.slice(5).toLowerCase() as StickDirection;
  return null;
}

/** A standard-layout gamepad's buttons, by position (bottom, right, left, top face...). */
const PAD: [number, HintButton][] = [
  [0, 'b'],
  [1, 'a'],
  [2, 'y'],
  [3, 'x'],
  [4, 'l'],
  [5, 'r'],
  [6, 'zl'],
  [7, 'zr'],
  [12, 'up'],
  [13, 'down'],
  [14, 'left'],
  [15, 'right'],
];

const NAMES: Record<HintButton, string> = {
  a: 'A',
  b: 'B',
  x: 'X',
  y: 'Y',
  l: 'L',
  zl: 'ZL',
  r: 'R',
  zr: 'ZR',
  up: 'up',
  down: 'down',
  left: 'left',
  right: 'right',
};
const empty = () => Object.fromEntries([...FACES, ...SHOULDERS, ...DIRECTIONS].map((b) => [b, 0]));

/** The last defined value of each key, so a hint going dark still shows its word as it leaves. */
function useLast<T extends string>(now: Partial<Record<T, string>>) {
  const last = useRef<Partial<Record<T, string>>>({});
  for (const key in now) if (now[key]) last.current[key] = now[key];
  return {
    ...last.current,
    ...Object.fromEntries(Object.entries(now).filter(([, v]) => v)),
  } as Partial<Record<T, string>>;
}

export function ButtonHints({
  actions,
  shoulders,
  combo,
  defaultActions = DEFAULT_ACTIONS,
  defaultShoulders = DEFAULT_SHOULDERS,
  defaultCombo = null,
  pressed,
  onPress,
  keys = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The face buttons' verbs, controlled: a button with a verb is lit. Turns the demo off. */
  actions?: FaceActions;
  /** The shoulder buttons' verbs, controlled: a shoulder hint shows while it has one. */
  shoulders?: ShoulderActions;
  /** A stick + face button combo to show, controlled; null for none. */
  combo?: ComboHint | null;
  /** Uncontrolled, the face verbs shown when the demo is not playing. */
  defaultActions?: FaceActions;
  /** Uncontrolled, the shoulder verbs shown when the demo is not playing. */
  defaultShoulders?: ShoulderActions;
  /** Uncontrolled, the combo shown when the demo is not playing. */
  defaultCombo?: ComboHint | null;
  /** Buttons held down, from a game's own input: each animates as pressed while listed. */
  pressed?: HintButton[];
  /** Called when a button goes down, from a key, a tap or a gamepad. */
  onPress?: (button: HintButton) => void;
  /** Listen to the keyboard (while focused) and to a connected gamepad. */
  keys?: boolean;
  /** While on screen and untouched, a short run of play: hints coming and going, presses landing. */
  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 maskId = `hints-mask-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
  const controlled = actions !== undefined || shoulders !== undefined || combo !== undefined;
  const demo = autoplay && playing && !controlled;
  const [own, setOwn] = useState<Context>({
    actions: defaultActions,
    shoulders: defaultShoulders,
    combo: defaultCombo,
  });
  const [ran, setRan] = useState(false);

  const nowActions = actions ?? own.actions;
  const nowShoulders = shoulders ?? own.shoulders;
  const nowCombo = combo !== undefined ? combo : own.combo;
  const lit: FaceActions = { ...nowActions };
  if (nowCombo) lit[nowCombo.button] = nowCombo.label;
  const faceWords = useLast<FaceButton>(lit);
  const shoulderWords = useLast<ShoulderButton>(nowShoulders);
  const lastCombo = useRef<ComboHint | null>(nowCombo);
  if (nowCombo) lastCombo.current = nowCombo;
  const shownCombo = nowCombo ?? lastCombo.current;

  // Presses: what is held (by any source) and how many times each went down (for the flash).
  const sources = useRef(new Map<string, HintButton>());
  const [held, setHeld] = useState<Record<string, number>>(empty);
  const [hits, setHits] = useState<Record<string, number>>(empty);
  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);
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

  const recount = () => {
    const next = empty();
    for (const button of sources.current.values()) next[button]++;
    setHeld(next);
  };
  const down = useEffectEvent((source: string, button: HintButton) => {
    if (sources.current.has(source)) return;
    sources.current.set(source, button);
    recount();
    setHits((now) => ({ ...now, [button]: now[button] + 1 }));
    onPress?.(button);
  });
  const up = useEffectEvent((source: string) => {
    if (!sources.current.delete(source)) return;
    recount();
  });
  const tap = useEffectEvent((button: HintButton, ms = TAP) => {
    const source = `tap-${button}-${Math.random()}`;
    down(source, button);
    later(ms, () => up(source));
  });

  // A game's own presses.
  const pressedKey = (pressed ?? []).join();
  useEffect(() => {
    const list = pressedKey ? (pressedKey.split(',') as HintButton[]) : [];
    for (const [source] of [...sources.current]) {
      if (source.startsWith('prop-') && !list.includes(source.slice(5) as HintButton)) up(source);
    }
    for (const button of list) down(`prop-${button}`, button);
  }, [pressedKey]);

  // The demo: contexts and presses, again and again while it may play.
  useEffect(() => {
    if (!demo) return;
    const run = () => {
      for (const step of DEMO) {
        later(step.at, () => {
          if (step.context) setOwn(step.context);
          step.press?.forEach((button) => tap(button, step.hold ?? TAP));
        });
      }
    };
    setRan(true);
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    const all = timers.current;
    return () => {
      window.clearInterval(loop);
      all.forEach((timer) => window.clearTimeout(timer));
      all.clear();
      for (const [source] of [...sources.current]) if (source.startsWith('tap-')) up(source);
    };
  }, [demo]);

  // When the reader takes over after the demo, the full set of hints comes back.
  const handOver = useCallback(() => {
    take();
    if (ran && !controlled) {
      setRan(false);
      setOwn({ actions: defaultActions, shoulders: defaultShoulders, combo: defaultCombo });
    }
  }, [take, ran, controlled, defaultActions, defaultShoulders, defaultCombo]);

  // Keys, while the component has focus.
  const onKeyDown = (event: React.KeyboardEvent) => {
    if (!keys) return;
    const button = keyButton(event);
    if (!button) return;
    const target = event.target as HTMLElement;
    if (target.closest('input, textarea, select, [contenteditable="true"]')) return;
    // Space and Enter on a focused button are its click; these are the controller's keys.
    event.preventDefault();
    handOver();
    if (!event.repeat) down(`key-${event.code}`, button);
  };
  const onKeyUp = (event: React.KeyboardEvent) => up(`key-${event.code}`);
  const onBlur = (event: React.FocusEvent) => {
    if (root.current?.contains(event.relatedTarget as Node)) return;
    for (const [source] of [...sources.current]) if (source.startsWith('key-')) up(source);
  };

  // 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 poll = useEffectEvent(() => {
    const now = new Set<string>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      for (const [index, button] of PAD) {
        if (gamepad.buttons[index]?.pressed) now.add(`pad-${gamepad.index}-${button}`);
      }
      const [x = 0, y = 0] = gamepad.axes;
      if (y < -0.5) now.add(`pad-${gamepad.index}-up`);
      if (y > 0.5) now.add(`pad-${gamepad.index}-down`);
      if (x < -0.5) now.add(`pad-${gamepad.index}-left`);
      if (x > 0.5) now.add(`pad-${gamepad.index}-right`);
    });
    for (const [source] of [...sources.current]) {
      if (source.startsWith('pad-') && !now.has(source)) up(source);
    }
    for (const source of now) {
      if (sources.current.has(source)) continue;
      handOver();
      down(source, source.split('-')[2] as HintButton);
    }
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick() {
      poll();
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  // A tap or click on a hint: down while the pointer is, a short press from the keyboard.
  const pointerHandlers = (button: HintButton) => ({
    onPointerDown: (event: React.PointerEvent) => {
      if (event.button !== 0) return;
      handOver();
      down(`pointer-${event.pointerId}`, button);
    },
    onPointerUp: (event: React.PointerEvent) => up(`pointer-${event.pointerId}`),
    onPointerLeave: (event: React.PointerEvent) => up(`pointer-${event.pointerId}`),
    onPointerCancel: (event: React.PointerEvent) => up(`pointer-${event.pointerId}`),
    onClick: (event: React.MouseEvent) => {
      if (event.detail === 0) {
        handOver();
        tap(button);
      }
    },
  });

  // What screen readers hear: the hints, when they change.
  const summary = [
    ...FACES.filter((b) => lit[b]).map((b) => `${NAMES[b]}: ${lit[b]}`),
    ...SHOULDERS.filter((b) => nowShoulders[b]).map((b) => `${NAMES[b]}: ${nowShoulders[b]}`),
    nowCombo && `stick ${nowCombo.direction} plus ${NAMES[nowCombo.button]}: ${nowCombo.label}`,
  ]
    .filter(Boolean)
    .join(', ');

  const isHeld = (button: HintButton) => held[button] > 0 || undefined;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      tabIndex={keys ? 0 : undefined}
      role="group"
      aria-label="Button hints"
      aria-keyshortcuts={
        keys ? 'A B X Y L R Shift+L Shift+R ArrowUp ArrowDown ArrowLeft ArrowRight' : undefined
      }
      onKeyDown={onKeyDown}
      onKeyUp={onKeyUp}
      onBlur={onBlur}
      onPointerDown={controlled ? undefined : handOver}
    >
      <div className={styles.fit}>
        <div className={styles.hud} data-part="hud">
          <div className={styles.cluster} data-part="cluster">
            <svg className={styles.clover} viewBox="-80 -80 160 160" aria-hidden="true">
              <defs>
                <mask id={maskId} className={styles.maskDef}>
                  <rect x="-80" y="-80" width="160" height="160" fill="#fff" />
                  {FACES.map((face) => (
                    <circle
                      key={face}
                      className={styles.knock}
                      data-on={lit[face] ? '' : undefined}
                      cx={STEP[face][0] * SPREAD}
                      cy={STEP[face][1] * SPREAD}
                      r="38"
                      fill="#000"
                    />
                  ))}
                </mask>
              </defs>
              <g mask={`url(#${maskId})`}>
                {FACES.map((face) => (
                  <circle
                    key={face}
                    className={styles.dim}
                    cx={STEP[face][0] * SPREAD}
                    cy={STEP[face][1] * SPREAD}
                    r="30"
                  />
                ))}
              </g>
            </svg>
            {FACES.map((face) => (
              <span
                key={face}
                className={styles.banner}
                data-part="banner"
                data-face={face}
                data-on={lit[face] ? '' : undefined}
                aria-hidden="true"
              >
                <span key={faceWords[face]} className={styles.word}>
                  <span
                    key={hits[face]}
                    className={styles.wordHit}
                    data-hit={hits[face] ? '' : undefined}
                  >
                    {faceWords[face]}
                  </span>
                </span>
              </span>
            ))}
            {FACES.map((face) => (
              <button
                key={face}
                type="button"
                className={styles.face}
                data-part="face"
                data-face={face}
                data-on={lit[face] ? '' : undefined}
                data-held={isHeld(face)}
                aria-label={
                  lit[face] ? `${NAMES[face]}: ${lit[face]}` : `${NAMES[face]}, no action`
                }
                aria-keyshortcuts={keys ? NAMES[face] : undefined}
                {...pointerHandlers(face)}
              >
                <span className={styles.press} aria-hidden="true" />
                <span className={styles.dimLetter} data-part="dim-letter" aria-hidden="true">
                  {NAMES[face]}
                </span>
                <span className={styles.chip} data-part="ring" aria-hidden="true">
                  <span className={styles.disc} data-part="disc">
                    {NAMES[face]}
                  </span>
                </span>
                {hits[face] > 0 && (
                  <span key={hits[face]} className={styles.burst} aria-hidden="true" />
                )}
              </button>
            ))}
          </div>

          {SHOULDERS.map((shoulder) => (
            <Shoulder
              key={shoulder}
              button={shoulder}
              word={shoulderWords[shoulder]}
              on={Boolean(nowShoulders[shoulder])}
              held={isHeld(shoulder)}
              hit={hits[shoulder]}
              keys={keys}
              handlers={pointerHandlers(shoulder)}
            />
          ))}

          <div className={styles.combo} data-part="combo" data-on={nowCombo ? '' : undefined}>
            <button
              type="button"
              className={styles.stick}
              data-part="stick"
              data-dir={shownCombo?.direction ?? 'up'}
              data-held={shownCombo ? isHeld(shownCombo.direction) : undefined}
              data-lean={DIRECTIONS.find((d) => held[d] > 0)}
              tabIndex={nowCombo ? 0 : -1}
              aria-hidden={nowCombo ? undefined : true}
              aria-label={
                shownCombo
                  ? `Tilt the stick ${shownCombo.direction} with ${NAMES[shownCombo.button]}: ${shownCombo.label}`
                  : 'Stick'
              }
              style={
                { '--turn': `${ARROW_TURN[shownCombo?.direction ?? 'up']}deg` } as CSSProperties
              }
              {...pointerHandlers(shownCombo?.direction ?? 'up')}
            >
              <StickIcon className={styles.stickIcon} />
            </button>
            <span className={styles.plus} data-part="plus" aria-hidden="true" />
          </div>
        </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.map((key) => (
                <kbd key={key}>{key}</kbd>
              ))}
            </Fragment>
          ))}
        </p>
      )}
      <p className={styles.live} aria-live="polite">
        {summary}
      </p>
    </div>
  );
}

/** A shoulder hint: keycap on its plate, and a banner with the verb, on the button's side. */
function Shoulder({
  button,
  word,
  on,
  held,
  hit,
  keys,
  handlers,
}: {
  button: ShoulderButton;
  word: string | undefined;
  on: boolean;
  held: true | undefined;
  hit: number;
  keys: boolean;
  handlers: Record<string, unknown>;
}) {
  const wide = button === 'zl' || button === 'zr';
  const right = button === 'r' || button === 'zr';
  // The key's outline, inset by 2 for its rounded stroke: L/R have a slanted outer side, ZL/ZR a
  // slanted top. Drawn for the left side, mirrored for the right.
  const width = wide ? 34 : 36;
  const points = wide ? '2,8 32,2 32,26 2,26' : '6,2 34,2 34,26 2,26';
  const mirror = right ? `translate(${width} 0) scale(-1 1)` : undefined;
  return (
    <button
      type="button"
      className={styles.shoulder}
      data-part="shoulder"
      data-button={button}
      data-side={right ? 'right' : 'left'}
      data-on={on ? '' : undefined}
      data-held={held}
      tabIndex={on ? 0 : -1}
      aria-hidden={on ? undefined : true}
      aria-label={`${NAMES[button]}: ${word ?? ''}`}
      aria-keyshortcuts={
        keys ? (wide ? `Shift+${NAMES[button].slice(1)}` : NAMES[button]) : undefined
      }
      {...handlers}
    >
      <svg
        className={styles.cap}
        data-part="keycap"
        data-wide={wide || undefined}
        viewBox={`-8 -8 ${width + 16} 44`}
        aria-hidden="true"
      >
        <g transform={mirror}>
          <polygon className={styles.plate} points={points} />
          <g className={styles.keyMove}>
            <polygon className={styles.key} points={points} />
          </g>
        </g>
        <g className={styles.keyMove}>
          <text
            className={styles.keyLabel}
            x={width / 2 + (wide ? 0 : right ? -1 : 1)}
            y={wide ? 16.5 : 14.5}
            fontSize={wide ? 17.5 : 23}
          >
            {NAMES[button]}
          </text>
        </g>
      </svg>
      <span className={styles.shoulderBanner} data-part="shoulder-banner" aria-hidden="true">
        <span key={word} className={styles.shoulderWord}>
          <span key={hit} className={styles.wordHit} data-hit={hit ? '' : undefined}>
            {word}
          </span>
        </span>
      </span>
    </button>
  );
}

/** The stick: a cap seen from above and a little in front, on its stem, with a tilt arrow. */
function StickIcon({ className }: { className: string }) {
  return (
    <svg className={className} viewBox="0 0 48 48" aria-hidden="true">
      <g className={styles.stickCap}>
        <path className={styles.stickStem} d="M14 31h20v6a4 4 0 0 1-4 4H18a4 4 0 0 1-4-4z" />
        <ellipse className={styles.stickRim} cx="24" cy="21" rx="17" ry="14" />
        <ellipse className={styles.stickTop} cx="24" cy="19.5" rx="12.5" ry="10" />
        <path className={styles.stickLetter} d="M21 13.5v11h7" />
      </g>
      <g className={styles.arrow}>
        <path d="M37 26l7 7h-4v8h-6v-8h-4z" />
      </g>
    </svg>
  );
}
```

### button-hints/button-hints.module.css

```css
/* Button hints (button-hints.tsx): the HUD's hints alone on the page's stage: the face buttons'
   clover, the shoulder hints above its sides and a stick combo below its left, with the keyboard
   keys behind them in small chips underneath.

   Every size is a multiple of --u, one design unit (1 px at full size): the root sets it from its
   own width (a container query unit), so the whole HUD scales as one; --hints-unit fixes it. The
   layout is the kit's: a 664 × 270 box with the clover's centre at (332, 166).

   Colours are the tokens below. The HUD's own pieces keep their colours, as a game's do: black
   rings and banners, dark plates, light discs and keycaps, each carrying its own dark ground, so
   they read on a light stage and a dark one alike. Only what sits straight on the stage (the
   press ring, focus, the key chips) follows the scheme, through light-dark().

   Motion: lighting a button scales its ring in with an overshoot while an SVG mask opens a gap in
   the dim clover; its banner wipes out by clip-path and its word slides in after. Presses are
   short transitions on the held state plus keyframes (a spreading ring, a flash) keyed per press. */

.root {
  /* The HUD. */
  --ink: #e3ded2;
  --disc: #e3ded2;
  --disc-ink: #0b0a09;
  --ring: #000;
  --banner: #000;
  --dim: #343130;
  --dim-ink: #7a7772;
  --plate: #35332f;
  --key: #e1ded2;
  --key-ink: #12100e;
  --bubble: #292723;
  --flash: #fff;
  /* On the stage itself: the edge that keeps black on black apart, the press ring, focus, chips. */
  --edge: rgb(0 0 0 / 0);
  --burst: #1b1a18;
  --focus: #1b1a18;
  --chip: rgb(0 0 0 / 0.05);
  --chip-line: rgb(0 0 0 / 0.14);
  --chip-ink: rgb(0 0 0 / 0.62);
  --u: var(--hints-unit, min(1px, calc((100cqi - 32px) / 664)));
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 20px;
  box-sizing: border-box;
  width: 100%;
  padding: 24px 16px;
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
  outline: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(0 0 0 / 0), rgb(255 255 255 / 0.14));
    --burst: light-dark(#1b1a18, #fff);
    --focus: light-dark(#1b1a18, #fff);
    --chip: 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;
}
.root:focus-visible {
  border-radius: 12px;
  box-shadow: inset 0 0 0 2px var(--focus);
}

.fit {
  display: grid;
  place-items: center;
}
.hud {
  position: relative;
  width: calc(664 * var(--u));
  height: calc(270 * var(--u));
  font-size: calc(28 * var(--u));
  line-height: 1;
  user-select: none;
  -webkit-user-select: none;
}

/* Buttons drawn as hints: no chrome of their own. */
.face,
.shoulder,
.stick {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
}

/* ---- The clover ---- */

.cluster {
  position: absolute;
  left: calc(332 * var(--u));
  top: calc(166 * var(--u));
  width: 0;
  height: 0;
}
.clover {
  position: absolute;
  left: calc(-80 * var(--u));
  top: calc(-80 * var(--u));
  width: calc(160 * var(--u));
  height: calc(160 * var(--u));
  overflow: visible;
}
.dim {
  fill: var(--dim);
}
/* The gap cut round a lit ring: a black circle in the mask, grown from nothing. */
.knock {
  transform-box: fill-box;
  transform-origin: center;
  scale: 0;
  transition: scale 200ms cubic-bezier(0.4, 0, 0.6, 1);
}
.knock[data-on] {
  scale: 1;
  transition: scale 320ms cubic-bezier(0.3, 1.5, 0.5, 1);
}

.face {
  --x: 0;
  --y: 0;
  position: absolute;
  z-index: 2;
  left: calc((var(--x) * 39 - 30) * var(--u));
  top: calc((var(--y) * 39 - 30) * var(--u));
  display: grid;
  place-items: center;
  width: calc(60 * var(--u));
  height: calc(60 * var(--u));
  border-radius: 50%;
}
.face[data-face='x'] {
  --y: -1;
}
.face[data-face='y'] {
  --x: -1;
}
.face[data-face='a'] {
  --x: 1;
}
.face[data-face='b'] {
  --y: 1;
}
.face:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(9 * var(--u));
}
.face > * {
  grid-area: 1 / 1;
}

/* A dim button: its letter, and a light wash while it is held. */
.dimLetter {
  color: var(--dim-ink);
  font-size: calc(30 * var(--u));
  font-weight: 500;
  transition:
    opacity 120ms,
    color 90ms;
}
.face[data-on] .dimLetter {
  opacity: 0;
}
.press {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--flash);
  opacity: 0;
  scale: 1;
  transition:
    opacity 220ms ease-out,
    scale 300ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.face:not([data-on])[data-held] .press {
  opacity: 0.12;
  scale: 0.92;
  transition-duration: 60ms;
}
.face:not([data-on])[data-held] .dimLetter {
  color: var(--ink);
}

/* A lit button: a light disc in a heavy black ring, swelling in from the dim circle. */
.chip {
  display: grid;
  place-items: center;
  width: calc(70 * var(--u));
  height: calc(70 * var(--u));
  border-radius: 50%;
  background: var(--ring);
  /* A faint edge on a dark stage, so the black ring keeps its round. */
  box-shadow: 0 0 0 calc(1.5 * var(--u)) var(--edge);
  opacity: 0;
  scale: 0.857;
  transition:
    scale 180ms cubic-bezier(0.4, 0, 0.6, 1),
    opacity 140ms ease-in;
}
.face[data-on] .chip {
  opacity: 1;
  scale: 1;
  transition:
    scale 320ms cubic-bezier(0.3, 1.5, 0.5, 1),
    opacity 90ms ease-out;
}
.face[data-on][data-held] .chip {
  scale: 0.88;
  transition: scale 60ms ease-out;
}
.disc {
  display: grid;
  place-items: center;
  width: calc(42 * var(--u));
  height: calc(42 * var(--u));
  border-radius: 50%;
  background: var(--disc);
  color: var(--disc-ink);
  font-size: calc(29 * var(--u));
  font-weight: 700;
  transition:
    background-color 240ms,
    box-shadow 240ms;
}
.face[data-held] .disc {
  background: var(--flash);
  box-shadow: 0 0 calc(14 * var(--u)) rgb(255 255 255 / 0.75);
  transition-duration: 40ms;
}
/* Each press: a light ring spreading from the button. */
.burst {
  width: calc(70 * var(--u));
  height: calc(70 * var(--u));
  box-sizing: border-box;
  border: calc(3 * var(--u)) solid var(--burst);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  animation: burst 460ms cubic-bezier(0.2, 0.7, 0.3, 1);
}

/* The banners: black, fading at their far end(s), wiping out from the button. */
.banner {
  position: absolute;
  z-index: 1;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  white-space: nowrap;
  pointer-events: none;
  clip-path: var(--closed);
  transition: clip-path 160ms cubic-bezier(0.4, 0, 1, 1);
}
.banner[data-on] {
  clip-path: inset(0 0 0 0);
  transition: clip-path 280ms 70ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.banner[data-face='x'],
.banner[data-face='b'] {
  --closed: inset(0 50% 0 50%);
  /* Over the ring, as the kit draws it: the word may run across the ring's black edge. */
  z-index: 3;
  left: calc(-100 * var(--u));
  justify-content: center;
  width: calc(200 * var(--u));
  height: calc(38 * var(--u));
  background: linear-gradient(
    90deg,
    transparent,
    var(--banner) 27.5%,
    var(--banner) 72.5%,
    transparent
  );
}
.banner[data-face='x'] {
  --from: 0 calc(10 * var(--u));
  top: calc(-97 * var(--u));
}
.banner[data-face='b'] {
  --from: 0 calc(-10 * var(--u));
  top: calc(59 * var(--u));
}
.banner[data-face='y'],
.banner[data-face='a'] {
  top: calc(-20 * var(--u));
  width: calc(183 * var(--u));
  height: calc(40 * var(--u));
}
.banner[data-face='y'] {
  --closed: inset(0 0 0 100%);
  --from: calc(14 * var(--u)) 0;
  left: calc(-222 * var(--u));
  justify-content: flex-end;
  padding-right: calc(40 * var(--u));
  background: linear-gradient(90deg, transparent, var(--banner) 62%);
}
.banner[data-face='a'] {
  --closed: inset(0 100% 0 0);
  --from: calc(-14 * var(--u)) 0;
  left: calc(39 * var(--u));
  padding-left: calc(40 * var(--u));
  background: linear-gradient(270deg, transparent, var(--banner) 62%);
}
.word,
.shoulderWord {
  display: block;
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.01em;
  opacity: 0;
  translate: var(--from, 0 0);
  transition:
    opacity 120ms,
    translate 160ms cubic-bezier(0.4, 0, 1, 1);
}
.banner[data-on] .word,
.shoulder[data-on] .shoulderWord {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity 200ms 150ms,
    translate 300ms 150ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
/* A new word on a hint that stays lit only fades in. */
.banner[data-on] .word,
.shoulder[data-on] .shoulderWord {
  animation: word 220ms ease-out;
}
.wordHit {
  display: block;
}
.wordHit[data-hit] {
  animation: hit 380ms cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* ---- Shoulder hints ---- */

.shoulder {
  position: absolute;
  z-index: 1;
  display: flex;
  align-items: center;
  height: calc(44 * var(--u));
  border-radius: calc(8 * var(--u));
  opacity: 0;
  visibility: hidden;
  translate: var(--away) 0;
  transition:
    opacity 140ms,
    translate 180ms cubic-bezier(0.4, 0, 1, 1),
    visibility 0s 180ms;
}
.shoulder[data-on] {
  opacity: 1;
  visibility: visible;
  translate: 0 0;
  transition:
    opacity 180ms,
    translate 340ms cubic-bezier(0.2, 0.9, 0.3, 1.15),
    visibility 0s;
}
.shoulder[data-side='left'] {
  --away: calc(-16 * var(--u));
  --from: calc(-10 * var(--u)) 0;
}
.shoulder[data-side='right'] {
  --away: calc(16 * var(--u));
  --from: calc(10 * var(--u)) 0;
  flex-direction: row-reverse;
}
.shoulder[data-button='l'] {
  left: calc(47 * var(--u));
  top: 0;
}
.shoulder[data-button='zl'] {
  left: calc(16 * var(--u));
  top: calc(64 * var(--u));
}
.shoulder[data-button='r'] {
  right: calc(47 * var(--u));
  top: 0;
}
.shoulder[data-button='zr'] {
  right: calc(16 * var(--u));
  top: calc(64 * var(--u));
}
.shoulder:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.cap {
  position: relative;
  z-index: 1;
  flex: none;
  width: calc(52 * var(--u));
  height: calc(44 * var(--u));
  overflow: visible;
}
.cap[data-wide] {
  width: calc(50 * var(--u));
}
/* The plate is the key's outline stroked wide (8 units round it), the key a thin round stroke. */
.plate {
  fill: var(--plate);
  stroke: var(--plate);
  stroke-width: 20;
  stroke-linejoin: round;
}
.key {
  fill: var(--key);
  stroke: var(--key);
  stroke-width: 4;
  stroke-linejoin: round;
  transition:
    fill 200ms,
    stroke 200ms;
}
.keyLabel {
  fill: var(--key-ink);
  font-weight: 700;
  text-anchor: middle;
  dominant-baseline: central;
}
.keyMove {
  transition: transform 260ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.shoulder[data-held] .keyMove {
  transform: translateY(2.5px);
  transition-duration: 60ms;
}
.shoulder[data-held] .key {
  fill: var(--flash);
  stroke: var(--flash);
  transition-duration: 40ms;
}
.shoulderBanner {
  display: flex;
  align-items: center;
  box-sizing: border-box;
  height: calc(36 * var(--u));
  white-space: nowrap;
}
.shoulder[data-side='left'] .shoulderBanner {
  margin-left: calc(-26 * var(--u));
  padding: 0 calc(36 * var(--u)) 0 calc(30.5 * var(--u));
  background: linear-gradient(
    90deg,
    var(--plate),
    var(--plate) calc(100% - 54 * var(--u)),
    transparent
  );
}
.shoulder[data-side='right'] .shoulderBanner {
  margin-right: calc(-26 * var(--u));
  padding: 0 calc(30.5 * var(--u)) 0 calc(36 * var(--u));
  background: linear-gradient(
    270deg,
    var(--plate),
    var(--plate) calc(100% - 54 * var(--u)),
    transparent
  );
}

/* ---- The stick combo ---- */

.combo {
  position: absolute;
  left: calc(56 * var(--u));
  top: calc(188 * var(--u));
  width: calc(140 * var(--u));
  height: calc(80 * var(--u));
  opacity: 0;
  visibility: hidden;
  scale: 0.8;
  transition:
    opacity 140ms,
    scale 180ms cubic-bezier(0.4, 0, 1, 1),
    visibility 0s 180ms;
}
.combo[data-on] {
  opacity: 1;
  visibility: visible;
  scale: 1;
  transition:
    opacity 180ms,
    scale 340ms cubic-bezier(0.3, 1.5, 0.5, 1),
    visibility 0s;
}
.stick {
  position: absolute;
  left: 0;
  top: 0;
  display: grid;
  place-items: center;
  width: calc(80 * var(--u));
  height: calc(80 * var(--u));
  border-radius: 50%;
  background: var(--bubble);
}
.stick:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.stickIcon {
  width: calc(54 * var(--u));
  height: calc(54 * var(--u));
  overflow: visible;
}
.stickCap {
  transition: transform 300ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.stick[data-lean='up'] .stickCap {
  transform: translate(0, -3.5px);
}
.stick[data-lean='down'] .stickCap {
  transform: translate(0, 3.5px);
}
.stick[data-lean='left'] .stickCap {
  transform: translate(-3.5px, 0);
}
.stick[data-lean='right'] .stickCap {
  transform: translate(3.5px, 0);
}
.stick[data-lean] .stickCap {
  transition-duration: 90ms;
}
.stickStem {
  fill: #b9b5aa;
  stroke: var(--bubble);
  stroke-width: 2;
}
.stickRim {
  fill: #b9b5aa;
  stroke: var(--bubble);
  stroke-width: 2;
}
.stickTop {
  fill: var(--key);
  stroke: var(--bubble);
  stroke-width: 2.2;
}
.stickLetter {
  fill: none;
  stroke: var(--key-ink);
  stroke-width: 2.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.arrow {
  transform-box: fill-box;
  transform-origin: center;
  rotate: var(--turn);
  fill: var(--key);
  stroke: var(--bubble);
  stroke-width: 2.5;
  stroke-linejoin: round;
  paint-order: stroke;
  transition: filter 200ms;
}
.stick[data-held] .arrow {
  fill: var(--flash);
  filter: drop-shadow(0 0 2px rgb(255 255 255 / 0.9));
  transition-duration: 40ms;
}
.plus {
  position: absolute;
  left: calc(116.5 * var(--u));
  top: calc(35.5 * var(--u));
  width: calc(15 * var(--u));
  height: calc(15 * var(--u));
  background:
    linear-gradient(var(--key), var(--key)) center / 100% calc(2 * var(--u)) no-repeat,
    linear-gradient(var(--key), var(--key)) center / calc(2 * var(--u)) 100% no-repeat;
}

/* ---- The keys, as small chips under the HUD, 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);
  font: inherit;
  font-weight: 600;
  text-align: center;
}
.legendGap {
  width: 6px;
}
@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 burst {
  from {
    opacity: 0.95;
    scale: 0.96;
  }
  to {
    opacity: 0;
    scale: 1.5;
  }
}
@keyframes hit {
  from {
    scale: 1.1;
    color: var(--flash);
    text-shadow: 0 0 0.3em rgb(255 255 255 / 0.8);
  }
}
@keyframes word {
  from {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .knock,
  .knock[data-on],
  .chip,
  .face[data-on] .chip,
  .face[data-on][data-held] .chip,
  .banner,
  .banner[data-on],
  .word,
  .shoulderWord,
  .banner[data-on] .word,
  .shoulder[data-on] .shoulderWord,
  .shoulder,
  .shoulder[data-on],
  .combo,
  .combo[data-on],
  .keyMove,
  .stickCap,
  .press {
    transition: none;
  }
  .face[data-on][data-held] .chip,
  .face:not([data-on])[data-held] .press {
    scale: 1;
  }
  .shoulder[data-held] .keyMove,
  .stick[data-lean] .stickCap {
    transform: none;
  }
  .burst,
  .wordHit[data-hit],
  .banner[data-on] .word,
  .shoulder[data-on] .shoulderWord {
    animation: none;
  }
}
```

### button-hints/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 };
}
```