# Quick selector

> A game’s d-pad gear cluster and the row it opens: hold a direction, flick to an item, let go to equip.

How a game lets the player swap gear without stopping: a d-pad cluster on the HUD shows the ability, shield and weapon in hand (arrows and bow once the bow is drawn), and holding a direction opens that slot’s row above it. A cream frame steps from tile to tile with the item’s name above it, the row scrolls and fades at its ends, and letting go floods the tile blue and pops the new item into its slot. Hold the arrow keys, or press and hold a slot.

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

Category: Games · Overlays.

## Anatomy

1. **Row**: The held slot’s items above the HUD, five tiles at a time; it rises in, and the strip slides to keep the frame one tile off its ends.
2. **Equipped tile**: The item in hand: a dark tile filled blue with a lighter rim. Equipping floods the new tile blue as the row fades.
3. **Frame**: The cream cursor with four corner ticks. Left and right glide it a tile at a time; letting go equips what is under it.
4. **Item name**: The name of the item under the frame, in white on a dark pill, so it reads on a light page too; it rises in on each step.
5. **Durability**: Uses left, in a dark box on the tile’s lower right corner, for weapons, shields and bows.
6. **Stick glyph**: A small stick drawn under the frame, saying “flick left or right”; its cream shapes carry a dark outline.
7. **HUD slot**: The equipped weapon in white on the d-pad’s right; the ability sits above and the shield to the left. Each is a button that opens its row.
8. **HUD disc**: The dark disc the cluster sits on, the HUD’s own contrast now that no game scene is behind it.
9. **Key legend**: Which keys do what, as small neutral chips under the element; left out with `bare` and on a phone.

## When to use

- Swapping gear in the middle of play without pausing: a weapon, a shield, an ability, an arrow type, a few items per slot.
- A game where each d-pad direction (or arrow key) owns one slot, and the player holds it, flicks to an item and lets go.
- A HUD that has to say at a glance what is in hand, and only shows the full list while a direction is held.
- A slot that changes meaning with a stance: the sides read shield and weapon in melee, arrows (with their count) and bow with the bow drawn.
- Gear that wears out or runs down, where the row shows durability or a count next to each item.

## When not to use

- A full inventory with dozens of items, sorting and descriptions. Use a paused inventory grid.
- Picking one of many options in an ordinary interface. Use a select, a menu or a segmented control.
- Spells or tools picked by direction from eight or more at once. Use a radial wheel.
- Abilities fired straight from a key, with no choice to make. Use an ability bar with cooldowns.
- A single on/off choice, such as drawing the bow on its own. Use a toggle or one button.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/quick-selector.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 { QuickSelector, type QuickCategory } from './quick-selector';

export function GearHud({
  gear,
  onEquip,
}: {
  gear: Record<QuickCategory, string>;
  onEquip: (category: QuickCategory, id: string) => void;
}) {
  return <QuickSelector bare equipped={gear} onEquip={onEquip} />;
}
```

## States

- **At rest**: Nothing held: only the HUD, with the time lock above, the iron shield left and the iron sword right.
- **Row open**: `defaultOpen="weapon"`: the frame starts on the blue, equipped tile, its name above it.
- **Browsing**: The frame on another item, the equipped one still blue; the last tile fades, for there are more.
- **Scrolled**: The frame near the end: the strip has slid one tile on, and now the first tile fades.
- **Abilities**: Up’s row: the abilities keep their colour and carry no durability.
- **Bow drawn**: `stance="ranged"`: the sides turn into arrows, with how many are left, and the bow; the legend adds F to fire.
- **Arrows**: Left’s row with the bow drawn: each arrow type shows its count, larger on the equipped one.
- **Out of arrows**: An arrow type at zero: its count on the HUD turns red, and firing does nothing.
- **Bare**: `bare`: no key legend, the HUD and its row alone, to lay over a game.

## How it works

- The HUD is a zero-size box at the d-pad’s centre: the four dots sit 13 units around it, the ability 45 units above, the shield or arrows 36 to the left and the weapon or bow 31 to the right, each a button. Its glyphs are the equipped items drawn in white (the ability keeps its colour), with a soft drop shadow, as on a game’s HUD.
- Holding a slot (an arrow key, or a press on it) opens its row with the frame on the equipped item. Left and right (or A/D, Q/E) step the frame; letting go equips what is under it. A press shorter than 260 ms is a tap instead: the row stays open for Enter, a click or a tap on a tile, and Escape cancels. Down draws or puts away the bow, which turns the sides into arrows and bow; F fires an arrow and spends one.
- The row shows five tiles. The strip keeps the frame one tile off its ends while there is more beyond, moving by translate (180 ms); the end tile on a side with more items fades out through a mask whose stops are two registered custom properties, so the fade eases in and out.
- The frame, the name, its dot and the stick glyph travel together in one layer that glides 140 ms per step; the new item bumps to 1.1, the name rises in, and at the end of the list the frame nudges 6 units out and back (Web Animations). Opening raises the row 10 units as its tiles stagger in 22 ms apart.
- Equipping floods the chosen tile blue from 0.55, fades the row out in 150 ms, and remounts the slot’s glyph, which pops from 0.6 through 1.16 with a white flash; drawing the bow flips the side slots in, and each shot pulses the arrow count, red at zero.
- Sizes are in units of the kit’s art (a tile is 112), turned into pixels from the stage’s width by a container query: 1 px at full size, 0.4 px on a narrow phone. The HUD is drawn 1.3 times larger with a 0.62 px floor, so the d-pad stays readable and pressable.
- It is drawn alone on the page, with no game scene, and brings its own contrast as a HUD over a bright sky does: the cluster sits on a dark disc, the item’s name on a dark pill, the tiles carry a hairline edge on a dark page and the stick glyph a dark outline. All of it is CSS and SVG at the screen’s resolution, with no blur on any shape; the glows are shadows behind sharp shapes. Room for the row is kept while it is closed, so the cluster never moves.
- Uncontrolled, it keeps its own gear and stance, and a demo plays a short run while it is on screen and untouched: a new weapon, the magnet, a bigger shield, the bow drawn, shots, fire arrows. Any real input hands it to the reader. equipped and stance control it; onEquip and onStanceChange report.

## API reference

- **items** (`Partial<Record<QuickCategory, QuickItem[]>>`, default `DEFAULT_ITEMS`): Each slot’s items in row order: { id, name, icon, tint?, count?, durability? }. icon names one of the component’s glyphs; count shows as ×N (arrows), durability in a dark box (gear). Categories: ability, weapon, shield, bow, arrow.
- **equipped** (`Partial<Record<QuickCategory, string>>`): The equipped item’s id in each slot, controlled. Pair it with onEquip. Turns the demo off.
- **defaultEquipped** (`Partial<Record<QuickCategory, string>>`, default `time lock, iron sword, iron shield, short bow, arrow`): Uncontrolled, the items equipped at the start.
- **onEquip** (`(category: QuickCategory, id: string) => void`): Called when the player lets go on an item (or picks one in an open row).
- **stance** (`'melee' | 'ranged'`): Controlled: melee shows shield and weapon on the sides, ranged shows arrows (with their count) and bow. Turns the demo off.
- **onStanceChange** (`(stance: 'melee' | 'ranged') => void`): Called when the player draws or puts away the bow (the d-pad’s down).
- **bare** (`boolean`, default `false`): No key legend under it: the HUD and its row alone, to lay over a game of your own.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play; real input stops it, and it never plays under reduced motion.
- **defaultOpen** (`'ability' | 'weapon' | 'shield' | 'bow' | 'arrow'`): Starts with this slot’s row open, as if tapped (it stays open until an item is picked or Escape), for a still. Turns the demo off.
- **defaultCursor** (`string`): With defaultOpen, the id of the item the frame starts on; the equipped one when left out.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the key chips and the tiles’ edges; without it, they follow the page. The disc, the pill and the tiles keep their own dark colours.
- **className** (`string`): Classes for the root, the stage.

## Accessibility

- The slots are buttons named by their slot and item (“Weapons: Iron sword”, “Arrows: Arrow, 58 left”), with aria-haspopup="listbox", aria-expanded and aria-keyshortcuts; Enter or Space opens a slot’s row and leaves it open.
- The open row is a listbox that takes focus, with each tile an option (its name, whether it is equipped, its count or durability) and aria-activedescendant on the frame’s tile; arrows or A/D move, Home and End jump, Enter equips, Escape cancels, and focus returns to the slot.
- Equips and stance changes are announced in a polite live region; the demo’s are not. The frame, the label above it, the stick glyph and the d-pad dots are aria-hidden: the listbox says the same.
- Every slot and tile works with a pointer alone (press and hold, or tap), and the d-pad keeps a minimum size on a phone. Under reduced motion nothing glides, bumps or pops, and the demo does not play.

## Source

### quick-selector/quick-selector.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useReducer,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { ItemIcon, PadDot, StickGlyph, type IconKind } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './quick-selector.module.css';

/*
 * Quick selector: how a game lets the player swap gear in the middle of play, without a menu.
 *
 * - The HUD cluster (always on, on its own dark disc) says what is in each slot at a glance: the
 *   ability above the d-pad, in its colour; the shield to its left and the weapon to its right, in
 *   white. With the bow drawn (the stance), the sides read arrows, with how many are left, and the
 *   bow.
 * - Holding a direction opens that slot's row above the cluster: the equipped item is the blue tile,
 *   and a cream frame (the cursor) starts on it, with the item's name above it and a stick glyph
 *   below that says "flick left or right". Left and right step the frame; letting go equips what
 *   is under it, and the row closes. A quick tap instead leaves the row open, for a keyboard or a
 *   touch screen: then Enter, a click or a tap on a tile equips, and Escape cancels.
 * - The row shows five tiles. The strip scrolls to keep the frame off its ends while there is
 *   more to see, and the end tile on a side with more items fades out. Arrows show a count (x58),
 *   gear its durability in a dark box.
 * - Motion is the HUD's: the held slot lifts and glows; the row rises in with its tiles staggered;
 *   the frame glides from tile to tile and the new item bumps; at the end of the list the frame
 *   nudges; equipping floods the tile blue and pops the new item into its HUD slot. Under reduced
 *   motion these are instant.
 * - It is drawn alone, with no game scene: the disc, the name's pill, the tiles' edges and the
 *   outlined stick glyph give it its contrast on a light or a dark page. Room for the row is kept
 *   while it is closed, so the cluster never moves.
 * - Uncontrolled it keeps its own state; `equipped` and `stance` control it. A demo plays a short
 *   run while on screen and untouched; any real input hands it to the reader.
 */

export type { IconKind };
export type QuickCategory = 'ability' | 'weapon' | 'shield' | 'bow' | 'arrow';
export type QuickStance = 'melee' | 'ranged';

export type QuickItem = {
  /** Stable id within its category. */
  id: string;
  /** Its name, shown above the frame. */
  name: string;
  /** Its art, from the component's own set. */
  icon: IconKind;
  /** A colour for its main shape, for a variant of the same art. */
  tint?: string;
  /** How many: arrows show it as ×N and on the HUD; firing spends one. */
  count?: number;
  /** Uses left: gear shows it in a dark box. */
  durability?: number;
};

const CATEGORIES: QuickCategory[] = ['ability', 'weapon', 'shield', 'bow', 'arrow'];
const CATEGORY_NAMES: Record<QuickCategory, string> = {
  ability: 'Abilities',
  weapon: 'Weapons',
  shield: 'Shields',
  bow: 'Bows',
  arrow: 'Arrows',
};

export const DEFAULT_ITEMS: Record<QuickCategory, QuickItem[]> = {
  ability: [
    { id: 'magnet', name: 'Magnet', icon: 'magnet' },
    { id: 'orb-bomb', name: 'Orb bomb', icon: 'orb-bomb' },
    { id: 'box-bomb', name: 'Box bomb', icon: 'box-bomb' },
    { id: 'time-lock', name: 'Time lock', icon: 'time-lock' },
    { id: 'frost', name: 'Frost pillar', icon: 'frost' },
  ],
  weapon: [
    { id: 'iron-sword', name: 'Iron sword', icon: 'sword', durability: 22 },
    { id: 'wood-axe', name: 'Woodcutter axe', icon: 'axe', durability: 30 },
    { id: 'root-club', name: 'Root club', icon: 'club', durability: 6 },
    { id: 'hunter-spear', name: 'Hunter spear', icon: 'spear', durability: 18 },
    { id: 'rune-blade', name: 'Rune blade', icon: 'sword', tint: '#5ce8ff', durability: 45 },
    { id: 'ember-sword', name: 'Ember sword', icon: 'sword', tint: '#ff8a4c', durability: 28 },
  ],
  shield: [
    { id: 'iron-shield', name: 'Iron shield', icon: 'heater', durability: 34 },
    { id: 'plank-shield', name: 'Plank shield', icon: 'round', durability: 12 },
    { id: 'tower-shield', name: 'Tower shield', icon: 'tower', durability: 47 },
    { id: 'rune-shield', name: 'Rune shield', icon: 'heater', tint: '#5ce8ff', durability: 28 },
  ],
  bow: [
    { id: 'short-bow', name: 'Short bow', icon: 'bow', durability: 24 },
    { id: 'long-bow', name: 'Long bow', icon: 'longbow', durability: 32 },
    { id: 'gilded-bow', name: 'Gilded bow', icon: 'bow', tint: '#f2c14e', durability: 45 },
    { id: 'rune-bow', name: 'Rune bow', icon: 'longbow', tint: '#5ce8ff', durability: 52 },
  ],
  arrow: [
    { id: 'arrow', name: 'Arrow', icon: 'arrow', count: 60 },
    { id: 'fire-arrow', name: 'Fire arrow', icon: 'fire-arrow', count: 12 },
    { id: 'ice-arrow', name: 'Ice arrow', icon: 'ice-arrow', count: 8 },
    { id: 'shock-arrow', name: 'Shock arrow', icon: 'shock-arrow', count: 15 },
    { id: 'bomb-arrow', name: 'Bomb arrow', icon: 'bomb-arrow', count: 5 },
  ],
};

const DEFAULT_EQUIPPED: Record<QuickCategory, string> = {
  ability: 'time-lock',
  weapon: 'iron-sword',
  shield: 'iron-shield',
  bow: 'short-bow',
  arrow: 'arrow',
};

/** Tiles the row shows at once. */
const WINDOW = 5;
/** A press shorter than this is a tap: the row stays open. */
const HOLD = 260;
/** How long the row takes to close, in ms. */
const CLOSE = 150;

type Dir = 'up' | 'left' | 'right' | 'down';
const slotOf = (dir: Exclude<Dir, 'down'>, stance: QuickStance): QuickCategory =>
  dir === 'up'
    ? 'ability'
    : dir === 'left'
      ? stance === 'ranged'
        ? 'arrow'
        : 'shield'
      : stance === 'ranged'
        ? 'bow'
        : 'weapon';

type Open = {
  cat: QuickCategory;
  cursor: number;
  /** The first tile in view. */
  first: number;
  /** Held (letting go equips) or tapped (stays open). */
  mode: 'hold' | 'tap';
  /** The key or 'pointer' holding it open. */
  by: string | null;
  at: number;
  closing: false | 'equip' | 'cancel';
  /** Opened by the demo: no focus moves, no announcements. */
  demo: boolean;
  /** Open from the first render (defaultOpen): focus stays where it is. */
  initial?: boolean;
};

type State = {
  open: Open | null;
  equipped: Record<QuickCategory, string>;
  stance: QuickStance;
  /** Counts spent by firing, by arrow id. */
  spent: Record<string, number>;
  /** The last equip, to pop its HUD slot and tell onEquip. */
  equip: { cat: QuickCategory; id: string; n: number; demo: boolean } | null;
  /** Bumped by each stance change and each shot, to replay their animations. */
  flips: number;
  shots: number;
  nudge: { dir: -1 | 1; n: number };
};

type Action =
  | {
      type: 'open';
      cat: QuickCategory;
      cursor: number;
      n: number;
      mode: 'hold' | 'tap';
      by: string | null;
      demo: boolean;
    }
  | { type: 'move'; by?: number; to?: number; n: number }
  | { type: 'tap' }
  | { type: 'close'; id?: string; demo: boolean; takeover?: boolean }
  | { type: 'closed' }
  | { type: 'stance'; stance: QuickStance }
  | { type: 'fire'; id: string }
  | { type: 'reset'; equipped: Record<QuickCategory, string> };

/** Keeps the cursor one tile off the window's ends while there is more beyond them. */
function frame(cursor: number, first: number, n: number) {
  const max = Math.max(0, n - WINDOW);
  let next = first;
  if (cursor < next + 1) next = cursor - 1;
  if (cursor > next + WINDOW - 2) next = cursor - (WINDOW - 2);
  return Math.min(max, Math.max(0, next));
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'open': {
      const cursor = Math.max(0, action.cursor);
      return {
        ...state,
        open: {
          cat: action.cat,
          cursor,
          first: frame(cursor, cursor - 1, action.n),
          mode: action.mode,
          by: action.by,
          at: performance.now(),
          closing: false,
          demo: action.demo,
        },
      };
    }
    case 'move': {
      const open = state.open;
      if (!open || open.closing) return state;
      const target = action.to ?? open.cursor + (action.by ?? 0);
      if (target < 0 || target >= action.n) {
        const dir = target < 0 ? -1 : 1;
        return { ...state, nudge: { dir, n: state.nudge.n + 1 } };
      }
      if (target === open.cursor) return state;
      return {
        ...state,
        open: { ...open, cursor: target, first: frame(target, open.first, action.n) },
      };
    }
    case 'tap':
      return state.open ? { ...state, open: { ...state.open, mode: 'tap', by: null } } : state;
    case 'close': {
      const open = state.open;
      if (!open || open.closing) return state;
      // The demo stopping does not close a row that was open from the start.
      if (action.takeover && open.initial) return state;
      if (!action.id) return { ...state, open: { ...open, closing: 'cancel' } };
      return {
        ...state,
        open: { ...open, closing: 'equip' },
        equipped: { ...state.equipped, [open.cat]: action.id },
        equip: { cat: open.cat, id: action.id, n: (state.equip?.n ?? 0) + 1, demo: action.demo },
      };
    }
    case 'closed':
      return { ...state, open: null };
    case 'stance':
      return state.stance === action.stance
        ? state
        : { ...state, stance: action.stance, open: null, flips: state.flips + 1 };
    case 'fire':
      return {
        ...state,
        spent: { ...state.spent, [action.id]: (state.spent[action.id] ?? 0) + 1 },
        shots: state.shots + 1,
      };
    case 'reset':
      return { ...state, open: null, equipped: action.equipped, stance: 'melee', spent: {} };
  }
}

/**
 * The demo: a short run of play, as steps at ms from its start. A new weapon, the magnet, a
 * bigger shield; then the bow drawn, two shots, fire arrows, one more shot, and the bow away.
 */
type Step =
  | { at: number; do: 'open'; dir: Exclude<Dir, 'down'> }
  | { at: number; do: 'move'; by: number }
  | { at: number; do: 'release' | 'fire' | 'reset' }
  | { at: number; do: 'stance'; stance: QuickStance };
const DEMO: Step[] = [
  { at: 0, do: 'reset' },
  { at: 900, do: 'open', dir: 'right' },
  { at: 1500, do: 'move', by: 1 },
  { at: 1900, do: 'move', by: 1 },
  { at: 2300, do: 'move', by: 1 },
  { at: 2700, do: 'move', by: 1 },
  { at: 3400, do: 'release' },
  { at: 4400, do: 'open', dir: 'up' },
  { at: 5000, do: 'move', by: -1 },
  { at: 5400, do: 'move', by: -1 },
  { at: 5800, do: 'move', by: -1 },
  { at: 6200, do: 'move', by: -1 },
  { at: 6800, do: 'release' },
  { at: 7800, do: 'open', dir: 'left' },
  { at: 8400, do: 'move', by: 1 },
  { at: 8800, do: 'move', by: 1 },
  { at: 9500, do: 'release' },
  { at: 10500, do: 'stance', stance: 'ranged' },
  { at: 11300, do: 'fire' },
  { at: 11900, do: 'fire' },
  { at: 12800, do: 'open', dir: 'left' },
  { at: 13400, do: 'move', by: 1 },
  { at: 14100, do: 'release' },
  { at: 14900, do: 'fire' },
  { at: 16000, do: 'stance', stance: 'melee' },
];
const DEMO_LENGTH = 17800;

export function QuickSelector({
  items = DEFAULT_ITEMS,
  equipped: equippedProp,
  defaultEquipped = DEFAULT_EQUIPPED,
  onEquip,
  stance: stanceProp,
  onStanceChange,
  bare = false,
  autoplay = true,
  defaultOpen,
  defaultCursor,
  colorScheme,
  className = '',
}: {
  /** The items of each slot, in row order. */
  items?: Partial<Record<QuickCategory, QuickItem[]>>;
  /** The equipped item's id in each slot, controlled. Pair it with onEquip. */
  equipped?: Partial<Record<QuickCategory, string>>;
  /** Uncontrolled, the items equipped at the start. */
  defaultEquipped?: Partial<Record<QuickCategory, string>>;
  /** Called when the player lets go on an item. */
  onEquip?: (category: QuickCategory, id: string) => void;
  /** Melee (shield and weapon on the sides) or ranged (arrows and bow), controlled. */
  stance?: QuickStance;
  /** Called when the player draws or puts away the bow (the d-pad's down). */
  onStanceChange?: (stance: QuickStance) => void;
  /** No key legend under it: the HUD and its row alone, to lay over a game of your own. */
  bare?: boolean;
  /** While on screen and untouched, a short run of play. */
  autoplay?: boolean;
  /** Starts with this slot's row open, as if tapped (it stays open), for a still. Turns the demo off. */
  defaultOpen?: QuickCategory;
  /** With defaultOpen, the id of the item the frame starts on; the equipped one when left out. */
  defaultCursor?: string;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const lists = useMemo(() => {
    const out = {} as Record<QuickCategory, QuickItem[]>;
    for (const cat of CATEGORIES) out[cat] = items[cat] ?? DEFAULT_ITEMS[cat];
    return out;
  }, [items]);
  const start = useMemo(() => {
    const out = {} as Record<QuickCategory, string>;
    for (const cat of CATEGORIES) out[cat] = defaultEquipped[cat] ?? lists[cat][0]?.id ?? '';
    return out;
    // Only the first render's value matters, as for an input's defaultValue.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const [state, dispatch] = useReducer(reducer, null, (): State => {
    let open: Open | null = null;
    if (defaultOpen && lists[defaultOpen]?.length) {
      const list = lists[defaultOpen];
      const find = (id: string | undefined) => list.findIndex((item) => item.id === id);
      const at = find(defaultCursor);
      const cursor = Math.max(
        0,
        at >= 0 ? at : find(equippedProp?.[defaultOpen] ?? start[defaultOpen]),
      );
      open = {
        cat: defaultOpen,
        cursor,
        first: frame(cursor, cursor - 1, list.length),
        mode: 'tap',
        by: null,
        at: 0,
        closing: false,
        demo: false,
        initial: true,
      };
    }
    return {
      open,
      equipped: start,
      stance: 'melee',
      spent: {},
      equip: null,
      flips: 0,
      shots: 0,
      nudge: { dir: 1, n: 0 },
    };
  });

  const equipped = { ...state.equipped, ...equippedProp };
  const stance = stanceProp ?? state.stance;
  const controlled =
    equippedProp !== undefined || stanceProp !== undefined || defaultOpen !== undefined;

  const { ref, take, playing: canPlay } = useAutoplay<HTMLDivElement>();
  const playing = autoplay && !controlled && canPlay;
  const playingRef = useRef(playing);
  playingRef.current = playing;

  const listId = useId();
  const listRef = useRef<HTMLUListElement>(null);
  const ringRef = useRef<HTMLSpanElement>(null);
  const slotRefs = useRef<Partial<Record<QuickCategory, HTMLButtonElement | null>>>({});
  const [live, setLive] = useState('');

  const open = state.open;
  const list = open ? lists[open.cat] : [];
  const indexOf = (cat: QuickCategory) =>
    Math.max(
      0,
      lists[cat].findIndex((item) => item.id === equipped[cat]),
    );
  const countOf = (item: QuickItem | undefined) =>
    item?.count === undefined ? undefined : Math.max(0, item.count - (state.spent[item.id] ?? 0));

  // --- Actions, shared by the reader's input and the demo.

  const openSlot = (cat: QuickCategory, mode: 'hold' | 'tap', by: string | null, demo = false) =>
    dispatch({ type: 'open', cat, cursor: indexOf(cat), n: lists[cat].length, mode, by, demo });
  const move = (by: number) => open && dispatch({ type: 'move', by, n: list.length });
  const moveTo = (to: number) => open && dispatch({ type: 'move', to, n: list.length });
  const equipAt = (index: number, demo = false) =>
    open && dispatch({ type: 'close', id: list[index]?.id, demo });
  const cancel = () => dispatch({ type: 'close', demo: false });
  const setStance = (next: QuickStance, demo = false) => {
    if (next === stance) return;
    dispatch({ type: 'stance', stance: next });
    if (!demo) {
      onStanceChange?.(next);
      setLive(next === 'ranged' ? 'Bow drawn: arrows and bow' : 'Bow away: shield and weapon');
    }
  };
  const fire = () => {
    if (stance !== 'ranged') return;
    const arrow = lists.arrow.find((item) => item.id === equipped.arrow);
    if (!arrow || (countOf(arrow) ?? 1) <= 0) return;
    dispatch({ type: 'fire', id: arrow.id });
  };

  // The row closes after its fade.
  useEffect(() => {
    if (!open?.closing) return;
    const timer = window.setTimeout(() => dispatch({ type: 'closed' }), CLOSE);
    return () => window.clearTimeout(timer);
  }, [open?.closing]);

  // Equips go out to onEquip and to screen readers.
  const reportEquip = useEffectEvent((cat: QuickCategory, id: string, demo: boolean) => {
    if (demo) return;
    onEquip?.(cat, id);
    const item = lists[cat].find((entry) => entry.id === id);
    if (item) setLive(`Equipped ${item.name}`);
  });
  useEffect(() => {
    if (state.equip) reportEquip(state.equip.cat, state.equip.id, state.equip.demo);
  }, [state.equip]);

  // Focus follows the row: into it when the reader opens it, back to its slot when it closes.
  const openCat = open?.cat;
  const openByReader = open && !open.demo && !open.initial;
  useEffect(() => {
    if (!openCat || !openByReader) return;
    listRef.current?.focus({ preventScroll: true });
    const slot = slotRefs.current[openCat];
    return () => {
      if (document.activeElement === listRef.current || document.activeElement === document.body)
        slot?.focus({ preventScroll: true });
    };
  }, [openCat, openByReader]);

  // At an end of the list the frame nudges outward and back.
  useEffect(() => {
    if (!state.nudge.n || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const shift = `calc(var(--u) * ${state.nudge.dir * 6})`;
    ringRef.current?.animate([{ translate: '0' }, { translate: shift }, { translate: '0' }], {
      duration: 200,
      easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)',
    });
  }, [state.nudge]);

  // --- The demo.

  const runStep = useEffectEvent((step: Step) => {
    switch (step.do) {
      case 'reset':
        dispatch({ type: 'reset', equipped: start });
        return;
      case 'open':
        openSlot(slotOf(step.dir, stance), 'hold', null, true);
        return;
      case 'move':
        move(step.by);
        return;
      case 'release':
        if (open) equipAt(open.cursor, true);
        return;
      case 'fire':
        fire();
        return;
      case 'stance':
        setStance(step.stance, true);
        return;
    }
  });
  useEffect(() => {
    if (!playing) {
      // The reader took over mid-run: a row the demo opened closes.
      dispatch({ type: 'close', demo: true, takeover: true });
      return;
    }
    let timers: number[] = [];
    const run = () => {
      timers = DEMO.map((step) => window.setTimeout(() => runStep(step), step.at));
      timers.push(window.setTimeout(run, DEMO_LENGTH));
    };
    run();
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [playing]);

  // --- The reader's input.

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const { key } = event;
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    if (open && !open.closing) {
      if (open.demo) return;
      if (key === open.by) {
        event.preventDefault();
        return;
      }
      const step =
        key === 'ArrowLeft' || key === 'a' || key === 'q'
          ? -1
          : key === 'ArrowRight' || key === 'd' || key === 'e'
            ? 1
            : 0;
      if (step) move(step);
      else if (key === 'Home') moveTo(0);
      else if (key === 'End') moveTo(list.length - 1);
      else if (key === 'Enter' || key === ' ') equipAt(open.cursor);
      else if (key === 'Escape' || key === 'Backspace') cancel();
      else if (key !== 'ArrowUp' && key !== 'ArrowDown') return;
      event.preventDefault();
      return;
    }
    if (event.repeat) return;
    const dir = (
      { ArrowUp: 'up', ArrowLeft: 'left', ArrowRight: 'right', ArrowDown: 'down' } as Record<
        string,
        Dir
      >
    )[key];
    if (dir === 'down') setStance(stance === 'ranged' ? 'melee' : 'ranged');
    else if (dir) openSlot(slotOf(dir, stance), 'hold', key);
    else if (key === 'f' || key === 'F') fire();
    else return;
    event.preventDefault();
  };

  const onKeyUp = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!open || open.closing || open.mode !== 'hold' || event.key !== open.by) return;
    if (performance.now() - open.at < HOLD) dispatch({ type: 'tap' });
    else equipAt(open.cursor);
  };

  // A slot held by the pointer: moving over the tiles moves the frame, letting go equips.
  const heldByPointer = open && !open.closing && open.by === 'pointer';
  const pointerMove = useEffectEvent((event: PointerEvent) => {
    const tile = document
      .elementFromPoint(event.clientX, event.clientY)
      ?.closest<HTMLElement>('[data-index]');
    if (tile && listRef.current?.contains(tile)) moveTo(Number(tile.dataset.index));
  });
  const pointerUp = useEffectEvent(() => {
    if (!open) return;
    if (performance.now() - open.at < HOLD) dispatch({ type: 'tap' });
    else equipAt(open.cursor);
  });
  useEffect(() => {
    if (!heldByPointer) return;
    const onMove = (event: PointerEvent) => pointerMove(event);
    const onUp = () => pointerUp();
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp);
    window.addEventListener('pointercancel', onUp);
    return () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      window.removeEventListener('pointercancel', onUp);
    };
  }, [heldByPointer]);

  const onRootPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    take();
    // A tap anywhere else on the stage closes a row left open.
    const target = event.target as HTMLElement;
    if (open && !open.closing && !target.closest('[data-part="row"], [data-part="hud"]')) cancel();
  };

  const onSlotPointerDown = (event: ReactPointerEvent, cat: QuickCategory) => {
    if (event.button !== 0) return;
    event.preventDefault();
    if (open && !open.closing && open.cat === cat) return cancel();
    openSlot(cat, 'hold', 'pointer');
  };
  const onSlotClick = (detail: number, cat: QuickCategory) => {
    // Pointer presses are handled on pointerdown; this is Enter or Space on the button.
    if (detail !== 0) return;
    take();
    if (open && open.cat === cat) cancel();
    else openSlot(cat, 'tap', null);
  };

  // --- Drawing.

  const cursorItem = open ? list[open.cursor] : undefined;
  const fades = open
    ? { left: open.first > 0 ? 1 : 0, right: open.first + WINDOW < list.length ? 1 : 0 }
    : { left: 0, right: 0 };

  const slot = (dir: Exclude<Dir, 'down'>) => {
    const cat = slotOf(dir, stance);
    const item = lists[cat].find((entry) => entry.id === equipped[cat]) ?? lists[cat][0];
    const count = cat === 'arrow' ? countOf(item) : undefined;
    const popped = state.equip?.cat === cat ? state.equip.n : 0;
    const held = open?.cat === cat && !open.closing;
    return (
      <button
        key={dir}
        type="button"
        ref={(element) => {
          slotRefs.current[cat] = element;
        }}
        className={styles.slot}
        data-dir={dir}
        data-held={held || undefined}
        data-part={`slot-${dir}`}
        aria-label={`${CATEGORY_NAMES[cat]}: ${item?.name ?? 'empty'}${count !== undefined ? `, ${count} left` : ''}`}
        aria-haspopup="listbox"
        aria-expanded={held}
        aria-controls={held ? listId : undefined}
        aria-keyshortcuts={{ up: 'ArrowUp', left: 'ArrowLeft', right: 'ArrowRight' }[dir]}
        onPointerDown={(event) => onSlotPointerDown(event, cat)}
        onClick={(event) => onSlotClick(event.detail, cat)}
      >
        {item && (
          <span
            key={`${stance}-${item.id}-${popped}`}
            className={styles.art}
            data-pop={popped ? '' : undefined}
            data-flip={!popped && state.flips && dir !== 'up' ? '' : undefined}
          >
            <ItemIcon kind={item.icon} mono className={styles.glyph} />
          </span>
        )}
        {count !== undefined && (
          <span
            key={state.shots}
            className={styles.ammo}
            data-part="ammo"
            data-shot={state.shots ? '' : undefined}
            data-empty={count === 0 || undefined}
            aria-hidden="true"
          >
            {count}
          </span>
        )}
      </button>
    );
  };

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      role="group"
      aria-label="Quick selector"
      onKeyDown={onKeyDown}
      onKeyUp={onKeyUp}
      onPointerDownCapture={onRootPointerDown}
    >
      <div className={styles.stage}>
        <div className={styles.rowZone}>
          {open && (
            <div
              className={styles.row}
              data-part="row"
              data-category={open.cat}
              data-closing={open.closing || undefined}
              style={
                {
                  '--first': open.first,
                  '--at': open.cursor - open.first,
                  '--qs-fade-l': fades.left,
                  '--qs-fade-r': fades.right,
                } as CSSProperties
              }
            >
              <div className={styles.window}>
                <ul
                  ref={listRef}
                  id={listId}
                  className={styles.strip}
                  role="listbox"
                  aria-label={CATEGORY_NAMES[open.cat]}
                  aria-activedescendant={`${listId}-${open.cursor}`}
                  tabIndex={-1}
                  data-part="strip"
                >
                  {list.map((item, index) => {
                    const count = countOf(item);
                    const isEquipped = item.id === equipped[open.cat];
                    return (
                      <li
                        key={item.id}
                        id={`${listId}-${index}`}
                        role="option"
                        aria-selected={index === open.cursor}
                        aria-label={`${item.name}${isEquipped ? ', equipped' : ''}${count !== undefined ? `, ${count} left` : ''}${item.durability !== undefined ? `, durability ${item.durability}` : ''}`}
                        className={styles.tile}
                        data-index={index}
                        data-part="tile"
                        data-equipped={isEquipped || undefined}
                        data-current={index === open.cursor || undefined}
                        data-flood={(isEquipped && open.closing === 'equip') || undefined}
                        style={{ '--i': Math.max(0, index - open.first) } as CSSProperties}
                        onClick={() => !open.closing && open.mode === 'tap' && equipAt(index)}
                      >
                        {isEquipped && <span className={styles.blue} aria-hidden="true" />}
                        <span className={styles.item} aria-hidden="true">
                          <ItemIcon kind={item.icon} tint={item.tint} className={styles.glyph} />
                        </span>
                        {count !== undefined && (
                          <span className={styles.count} aria-hidden="true" data-part="count">
                            ×{count}
                          </span>
                        )}
                        {item.durability !== undefined && (
                          <span
                            className={styles.durability}
                            aria-hidden="true"
                            data-part="durability"
                          >
                            {item.durability}
                          </span>
                        )}
                      </li>
                    );
                  })}
                </ul>
              </div>
              <div className={styles.focus} aria-hidden="true" data-part="focus">
                <span key={cursorItem?.id} className={styles.label} data-part="label">
                  {cursorItem?.name}
                </span>
                <span className={styles.dot} />
                <span ref={ringRef} className={styles.ring} data-part="ring">
                  <i />
                  <i />
                  <i />
                  <i />
                </span>
                <span className={styles.stick} data-part="stick">
                  <StickGlyph />
                </span>
              </div>
            </div>
          )}
        </div>

        <div className={styles.plate} data-part="plate">
          <div className={styles.hud} data-part="hud" data-stance={stance}>
            {slot('up')}
            {slot('left')}
            {slot('right')}
            <span className={styles.pad} aria-hidden="true" data-part="pad">
              {(['up', 'left', 'right', 'down'] as const).map((dir) => (
                <span
                  key={dir}
                  className={styles.padDot}
                  data-dir={dir}
                  data-lit={
                    (dir !== 'down' && open && !open.closing && open.cat === slotOf(dir, stance)) ||
                    undefined
                  }
                >
                  <PadDot dir={dir} />
                </span>
              ))}
            </span>
            <button
              type="button"
              className={styles.down}
              data-part="slot-down"
              aria-label={stance === 'ranged' ? 'Put the bow away' : 'Draw the bow'}
              aria-pressed={stance === 'ranged'}
              aria-keyshortcuts="ArrowDown"
              onClick={() => setStance(stance === 'ranged' ? 'melee' : 'ranged')}
            />
          </div>
        </div>

        {!bare && (
          <p className={styles.legend} data-part="legend">
            <span>
              Hold <kbd>↑</kbd>
              <kbd>←</kbd>
              <kbd>→</kbd> to choose
            </span>
            <span>
              <kbd>↓</kbd> {stance === 'ranged' ? 'put bow away' : 'draw bow'}
            </span>
            {stance === 'ranged' && (
              <span>
                <kbd>F</kbd> fire
              </span>
            )}
          </p>
        )}
      </div>
      <p className={styles.live} aria-live="polite">
        {live}
      </p>
    </div>
  );
}
```

### quick-selector/icons.tsx

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

/*
 * The quick selector's item art: flat, bold glyphs on a 64-unit square, drawn for it. Each is
 * painted with three custom properties: --ia (the main shape), --ib (the second material: grips,
 * shafts, rims) and --ic (details and cut lines). A tile gives each kind its own colours (a tint
 * replaces the main one); the HUD draws them mono: white, with the cut lines in the dark of its
 * drop shadow, so a shield keeps its rim and a blade its fuller.
 */

import type { CSSProperties } from 'react';

export type IconKind =
  | 'magnet'
  | 'orb-bomb'
  | 'box-bomb'
  | 'time-lock'
  | 'frost'
  | 'sword'
  | 'axe'
  | 'club'
  | 'spear'
  | 'heater'
  | 'round'
  | 'tower'
  | 'bow'
  | 'longbow'
  | 'arrow'
  | 'fire-arrow'
  | 'ice-arrow'
  | 'shock-arrow'
  | 'bomb-arrow';

/** Each kind's colours on a tile: main, second, detail. */
const PALETTE: Record<IconKind, [string, string, string]> = {
  magnet: ['#ff5d6c', '#ff5d6c', '#ff5d6c'],
  'orb-bomb': ['#16a6ff', '#16a6ff', '#0b5f99'],
  'box-bomb': ['#16a6ff', '#16a6ff', '#0b5f99'],
  'time-lock': ['#ffe066', '#ffe066', '#3d3418'],
  frost: ['#a4e6ff', '#a4e6ff', '#a4e6ff'],
  sword: ['#d9dee4', '#8a5a3a', '#7d8794'],
  axe: ['#c9d0d8', '#9b6a42', '#6c7682'],
  club: ['#b9783f', '#e3b07a', '#6e4322'],
  spear: ['#d9dee4', '#a3774c', '#5a3a22'],
  heater: ['#c9cfd6', '#9aa3ad', '#2f3439'],
  round: ['#9a6334', '#d39a5f', '#5d3a1d'],
  tower: ['#b9c0c8', '#7e8893', '#2f3439'],
  bow: ['#b98250', '#e7dcc4', '#6e4a2a'],
  longbow: ['#8f6a4a', '#e7dcc4', '#54391f'],
  arrow: ['#d2d8de', '#b79a72', '#e9edf0'],
  'fire-arrow': ['#ff5a3c', '#b79a72', '#e9edf0'],
  'ice-arrow': ['#4fb8ff', '#b79a72', '#e9edf0'],
  'shock-arrow': ['#ffd23c', '#b79a72', '#e9edf0'],
  'bomb-arrow': ['#e8492e', '#b79a72', '#e9edf0'],
};

/** Shapes drawn upright, then turned 45° so they lie corner to corner, as a game's item art does. */
const DIAGONAL = 'rotate(45 32 32)';

function Arrow({ ball = false, mono = false }: { ball?: boolean; mono?: boolean }) {
  return (
    <>
      <path
        d="M17 17 L50 50"
        stroke="var(--ib)"
        strokeWidth={mono ? 4.6 : 3.4}
        strokeLinecap="round"
      />
      {ball ? (
        <circle cx="14" cy="14" r="7.5" fill="var(--ia)" />
      ) : (
        <path d="M6 6 L24 11 L18.5 18.5 L11 24 Z" fill="var(--ia)" />
      )}
      <path
        d={
          mono
            ? 'M42 54 L54 42 L57.5 45.5 L45.5 57.5 Z'
            : 'M44 51 L50 57 L55 52 L51 50 Z M51 44 L57 50 L52 55 L50 51 Z'
        }
        fill={mono ? 'var(--ia)' : 'var(--ic)'}
      />
    </>
  );
}

function Shapes({ kind, mono }: { kind: IconKind; mono: boolean }) {
  switch (kind) {
    case 'magnet':
      return (
        <g transform={`${DIAGONAL} translate(0 6)`}>
          <path
            d="M19 17 V35 A13 13 0 0 0 45 35 V17"
            fill="none"
            stroke="var(--ia)"
            strokeWidth="11"
          />
          <rect x="13.5" y="4" width="11" height="8" fill="var(--ib)" />
          <rect x="39.5" y="4" width="11" height="8" fill="var(--ib)" />
        </g>
      );
    case 'orb-bomb':
      return (
        <>
          <circle cx="28" cy="37" r="21" fill="var(--ia)" />
          <path d="M41 23 L46 18" stroke="var(--ia)" strokeWidth="6" />
          <circle cx="49.5" cy="13.5" r="5.5" fill="none" stroke="var(--ia)" strokeWidth="4" />
        </>
      );
    case 'box-bomb':
      return (
        <>
          <path d="M11 22 H53 V56 H11 Z" fill="var(--ia)" />
          <path d="M26 22 L32 27 L38 22 Z" fill="var(--ic)" />
          <path d="M32 21 V15" stroke="var(--ia)" strokeWidth="4" />
          <circle cx="32" cy="10" r="5.5" fill="none" stroke="var(--ia)" strokeWidth="4" />
        </>
      );
    case 'time-lock':
      return (
        <>
          <path
            d="M20.5 30 V22 A11.5 11.5 0 0 1 43.5 22 V30"
            fill="none"
            stroke="var(--ia)"
            strokeWidth="6.5"
          />
          <rect x="12" y="28" width="40" height="31" rx="4" fill="var(--ia)" />
          <circle cx="32" cy="40.5" r="5" fill="var(--ic)" />
          <path d="M30 43 H34 L35 51 H29 Z" fill="var(--ic)" />
        </>
      );
    case 'frost':
      return (
        <g stroke="var(--ia)" strokeWidth="4.5" strokeLinecap="round" fill="none">
          {[0, 60, 120].map((angle) => (
            <g key={angle} transform={`rotate(${angle} 32 32)`}>
              <path d="M32 6 V58" />
              <path d="M25 11 L32 17 L39 11 M25 53 L32 47 L39 53" />
            </g>
          ))}
        </g>
      );
    case 'sword':
      return (
        <g transform={DIAGONAL}>
          <path d="M24 41 V12 L32 0 L40 12 V41 Z" fill="var(--ia)" />
          <path d="M30.8 14 H33.2 V37 H30.8 Z" fill="var(--ic)" />
          <rect x="17" y="41" width="30" height="6" rx="1.5" fill="var(--ib)" />
          <rect x="28.75" y="47" width="6.5" height="11" fill="var(--ib)" />
          <circle cx="32" cy="59.5" r="3.5" fill="var(--ib)" />
        </g>
      );
    case 'axe':
      return (
        <g transform={DIAGONAL}>
          <rect x="29.5" y="8" width="5" height="54" rx="2" fill="var(--ib)" />
          <path d="M34 9 C47 6 54 15 54 29 C48 25 41 25 34 28 Z" fill="var(--ia)" />
          <path d="M30 12 L20 17 L30 23 Z" fill="var(--ia)" />
          <path d="M37 13 C44 12 49 16 50 22" stroke="var(--ic)" strokeWidth="2" fill="none" />
        </g>
      );
    case 'club':
      return (
        <g transform={DIAGONAL}>
          <path d="M29 61 H35 L41 17 C43 3 21 3 23 17 Z" fill="var(--ia)" />
          <path d="M27 14 C28 9 36 9 37 14" stroke="var(--ib)" strokeWidth="3" fill="none" />
          <circle cx="35" cy="24" r="2.4" fill="var(--ic)" />
          <circle cx="29" cy="33" r="1.8" fill="var(--ic)" />
          <rect x="28.5" y="48" width="7" height="3" fill="var(--ic)" />
        </g>
      );
    case 'spear':
      return (
        <g transform={DIAGONAL}>
          <rect x="30.5" y="16" width="3" height="47" rx="1.5" fill="var(--ib)" />
          <path d="M32 0 L38 13 L32 18 L26 13 Z" fill="var(--ia)" />
          <rect x="28.5" y="17" width="7" height="4" rx="1" fill="var(--ic)" />
        </g>
      );
    case 'heater':
      return (
        <>
          <path d="M10 7 H54 V30 C54 46 44 54 32 60 C20 54 10 46 10 30 Z" fill="var(--ia)" />
          <path
            d="M15.5 12.5 H48.5 V30 C48.5 43 40.5 50 32 54 C23.5 50 15.5 43 15.5 30 Z"
            fill="var(--ic)"
          />
          <path
            d="M20.5 17.5 H43.5 V30 C43.5 40 38 45.5 32 48.5 C26 45.5 20.5 40 20.5 30 Z"
            fill="var(--ib)"
          />
        </>
      );
    case 'round':
      return (
        <>
          <circle cx="32" cy="32" r="27" fill="var(--ia)" />
          <circle cx="32" cy="32" r="22" fill="var(--ib)" />
          <path d="M20 14 V50 M32 10 V54 M44 14 V50" stroke="var(--ic)" strokeWidth="1.6" />
          <circle cx="32" cy="32" r="7" fill="var(--ia)" />
          <circle cx="32" cy="32" r="3" fill="var(--ic)" />
        </>
      );
    case 'tower':
      return (
        <>
          <path d="M13 5 H51 V44 L32 61 L13 44 Z" fill="var(--ia)" />
          <path d="M18 10 H46 V42 L32 54.5 L18 42 Z" fill="var(--ic)" />
          <path d="M21 13 H43 V40.5 L32 50.5 L21 40.5 Z" fill="var(--ib)" />
          <path d="M32 13 V50 M21 27 H43" stroke="var(--ic)" strokeWidth="3" />
        </>
      );
    case 'bow':
    case 'longbow': {
      const long = kind === 'longbow';
      return (
        <>
          <path
            d={long ? 'M58 14 C34 4 10 22 14 58' : 'M54 18 C38 6 14 22 18 54'}
            fill="none"
            stroke="var(--ia)"
            strokeWidth={long ? 4.5 : 5.5}
            strokeLinecap="round"
          />
          <path d={long ? 'M58 14 L14 58' : 'M54 18 L18 54'} stroke="var(--ib)" strokeWidth="1.6" />
          <rect
            x="27"
            y="17"
            width="7"
            height="5"
            rx="1"
            transform="rotate(-45 30.5 19.5)"
            fill="var(--ic)"
          />
          {mono && (
            <>
              <path d="M10 10 L40 40" stroke="var(--ia)" strokeWidth="3.4" />
              <path d="M5 5 L18 8.5 L8.5 18 Z" fill="var(--ia)" />
              <path d="M36 42 L42 36 L47 41 L41 47 Z" fill="var(--ia)" />
            </>
          )}
        </>
      );
    }
    case 'arrow':
    case 'fire-arrow':
    case 'ice-arrow':
    case 'shock-arrow':
      return <Arrow mono={mono} />;
    case 'bomb-arrow':
      return <Arrow ball mono={mono} />;
  }
}

export function ItemIcon({
  kind,
  tint,
  mono = false,
  className,
}: {
  kind: IconKind;
  /** Replaces the kind's main colour, for a variant of the same item (a rune blade, a gilded bow). */
  tint?: string;
  /** White, for the HUD. */
  mono?: boolean;
  className?: string;
}) {
  const [a, b, c] = PALETTE[kind];
  const style = (
    mono
      ? { '--ia': '#fff', '--ib': '#fff', '--ic': '#4a4844' }
      : { '--ia': tint ?? a, '--ib': b, '--ic': c }
  ) as CSSProperties;
  // Only an ability keeps its colour on the HUD; the gear reads in white there.
  const keep = mono && ['magnet', 'orb-bomb', 'box-bomb', 'time-lock', 'frost'].includes(kind);
  return (
    <svg
      className={className}
      viewBox="0 0 64 64"
      aria-hidden="true"
      focusable="false"
      style={keep ? ({ '--ia': a, '--ib': b, '--ic': c } as CSSProperties) : style}
    >
      <Shapes kind={kind} mono={mono} />
    </svg>
  );
}

/** The d-pad's four buttons, as the HUD draws them: white dots, each with its arrow. */
export function PadDot({ dir }: { dir: 'up' | 'left' | 'right' | 'down' }) {
  const turn = { up: 0, right: 90, down: 180, left: 270 }[dir];
  return (
    <svg viewBox="0 0 12 12" aria-hidden="true" focusable="false">
      <circle cx="6" cy="6" r="6" fill="#fff" />
      <path d="M6 3.6 L8 6.9 H4 Z" fill="#4a4844" transform={`rotate(${turn} 6 6)`} />
    </svg>
  );
}

/**
 * The right stick with its left and right arrows: "flick to move along the row". Its cream shapes
 * carry a dark outline under their fill (paint-order), so they read on a light stage as well.
 */
export function StickGlyph() {
  const outline = {
    stroke: '#1b1b1a',
    strokeWidth: 2.4,
    strokeLinejoin: 'round',
    paintOrder: 'stroke',
  } as const;
  return (
    <svg viewBox="0 0 53 29" aria-hidden="true" focusable="false">
      <path d="M1.5 13.5 L9 7.5 V19.5 Z M51.5 13.5 L44 7.5 V19.5 Z" fill="#e2ded3" {...outline} />
      <path d="M21 24.5 H32 L30.5 28.5 H22.5 Z" fill="#e2ded3" {...outline} />
      <circle cx="26.5" cy="13" r="13" fill="#e2ded3" {...outline} />
      <circle cx="26.5" cy="13" r="10" fill="none" stroke="#3a3936" strokeWidth="1.6" />
      <text
        x="26.5"
        y="17.6"
        textAnchor="middle"
        fontSize="13"
        fontWeight="700"
        fill="#3a3936"
        fontFamily="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
      >
        R
      </text>
    </svg>
  );
}
```

### quick-selector/quick-selector.module.css

```css
/* Quick selector (quick-selector.tsx): the HUD's d-pad cluster and, while a slot is held, that
   slot's row of items above it, alone on the page's stage.

   Sizes are in --u, one unit of the kit's art (a tile is 112 units, the HUD's magnet 36), which
   the stage sets from its width (a container query): 1 px at full size, down to 0.4 px on a narrow
   phone. The HUD is drawn 1.3 times larger, with a floor, so its d-pad stays readable and
   pressable.

   With no game scene behind it, the element brings its own contrast, as a HUD over a bright sky
   does: the cluster sits on a dark disc, the item's name on a dark pill, the tiles carry a hairline
   edge on a dark stage, and the cream glyphs are outlined in dark. Those plates are the same on
   both schemes; only the stage around them and the key chips follow the scheme.

   Everything is CSS and SVG, drawn at the screen's resolution: no blur on any shape. The glows are
   box shadows and drop shadows, layers behind sharp shapes.

   Motion: the row rises in and its tiles stagger in (keyframes on mount); the strip and the frame
   glide by transitions of translate; the bump, the flood, the pop, the flip and the shot are
   keyframes on the parts they move. Under reduced motion, all of it is instant. */

@property --qs-fade-l {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
@property --qs-fade-r {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

.root {
  /* The HUD's own colours, the same on both schemes. */
  --qs-ink: #fff;
  --qs-cream: #e2ded3;
  --qs-cream-line: #cfcabd;
  --qs-tile: #141413;
  --qs-tile-line: #252524;
  --qs-equipped: #0390f9;
  --qs-equipped-rim: #46aefb;
  --qs-box: #080807;
  --qs-box-line: #343433;
  --qs-cut: #4a4844;
  --qs-warn: #ff5d6c;
  --qs-shadow: rgb(0 0 0 / 0.38);
  /* Its plates: the d-pad's disc and the name's pill. */
  --qs-plate: rgb(18 18 17 / 0.94);
  --qs-plate-line: rgb(255 255 255 / 0.08);
  --qs-pill: rgb(14 14 13 / 0.9);
  /* What follows the scheme: the plates' and tiles' lift off the stage, and the key chips. */
  --qs-lift: 0 6px 18px rgb(0 0 0 / 0.16);
  --qs-edge: rgb(0 0 0 / 0.1);
  --qs-chip: #f1f1ef;
  --qs-chip-line: #e1e1dd;
  --qs-chip-ink: #5c5c58;
  --qs-chip-key: #fff;
  --focus: #fff;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  width: 100%;
  padding: 20px 0;
  box-sizing: border-box;
  color: var(--qs-ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --qs-lift: light-dark(0 6px 18px rgb(0 0 0 / 0.16), 0 6px 18px rgb(0 0 0 / 0.4));
    --qs-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.16));
    --qs-chip: light-dark(#f1f1ef, #222221);
    --qs-chip-line: light-dark(#e1e1dd, #343432);
    --qs-chip-ink: light-dark(#5c5c58, #b2b2ad);
    --qs-chip-key: light-dark(#fff, #30302e);
  }
}
.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;
}

/* The unit lives on a child, where the root's width (cqw) is known. */
.stage {
  --u: clamp(0.4px, 100cqw / 720, 1px);
  --tile: calc(var(--u) * 112);
  --pitch: calc(var(--u) * 138);
  --pad: calc(var(--u) * 8);
  --hud-u: max(0.62px, var(--u) * 1.3);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(var(--u) * 14);
}

/* The row's room, kept while it is closed so the HUD below never moves. */
.rowZone {
  position: relative;
  width: 100%;
  height: calc(var(--u) * 238);
}

/* ---------- The row ---------- */

.row {
  position: absolute;
  top: 0;
  left: 50%;
  translate: -50% 0;
  padding-top: calc(var(--u) * 70);
  padding-bottom: calc(var(--u) * 54);
  animation: row-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.row[data-closing] {
  animation: row-out 150ms ease-in forwards;
  pointer-events: none;
}
@keyframes row-in {
  from {
    opacity: 0;
    translate: -50% calc(var(--u) * 10);
  }
}
@keyframes row-out {
  to {
    opacity: 0;
    translate: -50% calc(var(--u) * -4);
  }
}

/* Five tiles' room, with a margin for a durability box's overhang; an end with more beyond it
   fades out across its tile. */
.window {
  --edge: calc(var(--pad) + var(--tile));
  width: calc(var(--tile) * 5 + var(--u) * 26 * 4 + var(--pad) * 2);
  margin: calc(var(--pad) * -1);
  padding: var(--pad);
  overflow: hidden;
  overflow: clip;
  mask-image: linear-gradient(
    to right,
    rgb(0 0 0 / calc(1 - var(--qs-fade-l))) var(--pad),
    #000 var(--edge),
    #000 calc(100% - var(--edge)),
    rgb(0 0 0 / calc(1 - var(--qs-fade-r))) calc(100% - var(--pad))
  );
  transition:
    --qs-fade-l 180ms ease,
    --qs-fade-r 180ms ease;
}

.strip {
  display: flex;
  gap: calc(var(--u) * 26);
  margin: 0;
  padding: 0;
  list-style: none;
  outline: none;
  translate: calc(var(--pitch) * var(--first) * -1) 0;
  transition: translate 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.tile {
  position: relative;
  flex: none;
  width: var(--tile);
  height: var(--tile);
  border-radius: calc(var(--u) * 2);
  background: var(--qs-tile);
  /* The inner hairline is the kit's; the outer one lifts the tile off a dark stage. */
  box-shadow:
    inset 0 0 0 calc(var(--u) * 3) var(--qs-tile),
    inset 0 0 0 calc(var(--u) * 4) var(--qs-tile-line),
    0 0 0 1px var(--qs-edge);
  cursor: pointer;
  animation: tile-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
  animation-delay: calc(min(var(--i), 5) * 22ms);
}
@keyframes tile-in {
  from {
    opacity: 0;
    scale: 0.9;
  }
}

/* The equipped item: a blue plate inside the tile's dark rim, lighter at its edge. */
.blue {
  position: absolute;
  inset: calc(var(--u) * 5);
  border-radius: calc(var(--u) * 1);
  background: var(--qs-equipped);
  box-shadow: inset 0 0 calc(var(--u) * 9) calc(var(--u) * 1.5) var(--qs-equipped-rim);
}
.tile[data-flood] .blue {
  animation: flood 170ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes flood {
  from {
    scale: 0.55;
    opacity: 0.4;
  }
}

/* Gear lies corner to corner, as big as the tile allows; an ability's glyph sits smaller. */
.item {
  position: absolute;
  inset: calc(var(--u) * 4);
  display: grid;
  place-items: center;
}
.row[data-category='ability'] .item {
  inset: calc(var(--u) * 16);
}
.item .glyph {
  width: 100%;
  height: 100%;
  overflow: visible;
  filter: drop-shadow(0 calc(var(--u) * 2) calc(var(--u) * 2) rgb(0 0 0 / 0.45));
}
.tile[data-current] .item {
  animation: bump 170ms cubic-bezier(0.3, 0.7, 0.4, 1.4);
}
@keyframes bump {
  50% {
    scale: 1.1;
  }
}

/* ×58: an arrow's count, bottom left. */
.count {
  position: absolute;
  left: calc(var(--u) * 14);
  bottom: calc(var(--u) * 6.5);
  font-size: calc(var(--u) * 21);
  font-weight: 500;
  line-height: 1;
  color: #f4f2ec;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 calc(var(--u) * 1) calc(var(--u) * 2) rgb(0 0 0 / 0.6);
}
.tile[data-equipped] .count {
  font-size: calc(var(--u) * 25);
  bottom: calc(var(--u) * 6.5);
}

/* 45: gear's durability, in a dark box over the bottom right corner. */
.durability {
  position: absolute;
  right: calc(var(--u) * -2);
  bottom: calc(var(--u) * -2);
  display: grid;
  place-items: center;
  min-width: calc(var(--u) * 50);
  height: calc(var(--u) * 34);
  padding: 0 calc(var(--u) * 6);
  box-sizing: border-box;
  border-radius: calc(var(--u) * 2);
  background: var(--qs-box);
  box-shadow:
    inset 0 0 0 calc(var(--u) * 2) var(--qs-box),
    inset 0 0 0 calc(var(--u) * 3) var(--qs-box-line);
  font-size: calc(var(--u) * 22);
  font-weight: 500;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

/* ---------- The frame, its label and the stick glyph: they travel together ---------- */

.focus {
  position: absolute;
  top: calc(var(--u) * 70);
  left: 0;
  width: var(--tile);
  height: var(--tile);
  pointer-events: none;
  translate: calc(var(--pitch) * var(--at)) 0;
  transition: translate 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* The name, on its own dark pill: white type needs it on a light stage. */
.label {
  position: absolute;
  left: 50%;
  bottom: calc(100% + var(--u) * 24);
  translate: -50% 0;
  padding: calc(var(--u) * 7) calc(var(--u) * 14);
  border-radius: calc(var(--u) * 40);
  background: var(--qs-pill);
  box-shadow:
    inset 0 0 0 1px var(--qs-plate-line),
    var(--qs-lift);
  font-size: calc(var(--u) * 22.5);
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  color: var(--qs-ink);
  animation: label-in 130ms ease-out;
}
@keyframes label-in {
  from {
    opacity: 0;
    translate: -50% calc(var(--u) * 5);
  }
}

.dot {
  position: absolute;
  left: 50%;
  top: calc(var(--u) * -16.5);
  width: calc(var(--u) * 4);
  height: calc(var(--u) * 4);
  margin-left: calc(var(--u) * -2);
  border-radius: 50%;
  background: var(--qs-cream);
  box-shadow: 0 0 0 max(1px, var(--u) * 1.2) var(--qs-tile);
}

/* The frame: a cream line with an inner and an outer glow, and a triangle in each corner. */
.ring {
  position: absolute;
  inset: 0;
  border: max(1px, var(--u) * 1.2) solid var(--qs-cream-line);
  border-radius: calc(var(--u) * 2);
  box-shadow:
    inset 0 0 calc(var(--u) * 8) rgb(196 186 128 / 0.6),
    0 0 calc(var(--u) * 11) calc(var(--u) * 1) rgb(226 222 205 / 0.55);
}
.ring i {
  position: absolute;
  width: calc(var(--u) * 9);
  height: calc(var(--u) * 9);
  background: var(--qs-cream);
}
.ring i:nth-child(1) {
  top: 0;
  left: 0;
  clip-path: polygon(0 0, 100% 0, 0 100%);
}
.ring i:nth-child(2) {
  top: 0;
  right: 0;
  clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.ring i:nth-child(3) {
  bottom: 0;
  right: 0;
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
}
.ring i:nth-child(4) {
  bottom: 0;
  left: 0;
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}

.stick {
  position: absolute;
  left: 50%;
  top: calc(100% + var(--u) * 19);
  width: calc(var(--u) * 53);
  height: calc(var(--u) * 29);
  translate: -50% 0;
}
.stick svg {
  display: block;
  overflow: visible;
  width: 100%;
  height: 100%;
}

/* ---------- The HUD: a dark disc, and in it a box whose origin is the d-pad's centre ---------- */

.plate {
  --u: var(--hud-u);
  position: relative;
  flex: none;
  width: calc(var(--u) * 150);
  height: calc(var(--u) * 150);
  border-radius: 50%;
  background: var(--qs-plate);
  box-shadow:
    inset 0 0 0 1px var(--qs-plate-line),
    0 0 0 1px var(--qs-edge),
    var(--qs-lift);
}

.hud {
  position: absolute;
  left: calc(50% - var(--u) * 1);
  top: calc(50% + var(--u) * 14);
  width: 0;
  height: 0;
}

.slot {
  position: absolute;
  display: grid;
  place-items: center;
  width: max(28px, var(--u) * 44);
  height: max(28px, var(--u) * 44);
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(var(--u) * 8);
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  translate: -50% -50%;
  touch-action: none;
}
.slot[data-dir='up'] {
  left: 0;
  top: calc(var(--u) * -45);
}
.slot[data-dir='left'] {
  left: calc(var(--u) * -36.5);
  top: calc(var(--u) * 2);
}
.slot[data-dir='right'] {
  left: calc(var(--u) * 33);
  top: calc(var(--u) * 2.5);
}
.hud[data-stance='ranged'] .slot[data-dir='left'] {
  left: calc(var(--u) * -36);
  top: calc(var(--u) * -1);
}
.hud[data-stance='ranged'] .slot[data-dir='right'] {
  left: calc(var(--u) * 31.5);
  top: calc(var(--u) * 1.5);
}
.slot:focus-visible,
.down:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.art {
  display: grid;
  place-items: center;
  filter: drop-shadow(0 calc(var(--u) * 2) calc(var(--u) * 3) var(--qs-shadow));
  transition:
    scale 120ms cubic-bezier(0.2, 0.8, 0.2, 1),
    filter 120ms ease;
}
.art .glyph {
  display: block;
  overflow: visible;
}
/* Each slot's art box, so its glyph's own extent matches the kit's (magnet 36 × 35, shield
   24 × 29, sword 31 × 28, arrow 26 × 24, bow 33 × 29). */
.slot[data-dir='up'] .glyph {
  width: calc(var(--u) * 44);
  height: calc(var(--u) * 44);
}
.slot[data-dir='left'] .glyph {
  width: calc(var(--u) * 36);
  height: calc(var(--u) * 36);
}
.slot[data-dir='right'] .glyph {
  width: calc(var(--u) * 41);
  height: calc(var(--u) * 41);
}
.hud[data-stance='ranged'] .slot[data-dir='left'] .glyph {
  width: calc(var(--u) * 34);
  height: calc(var(--u) * 34);
}
.hud[data-stance='ranged'] .slot[data-dir='right'] .glyph {
  width: calc(var(--u) * 41);
  height: calc(var(--u) * 41);
}

.slot[data-held] .art {
  scale: 1.14;
  filter: drop-shadow(0 calc(var(--u) * 2) calc(var(--u) * 3) var(--qs-shadow))
    drop-shadow(0 0 calc(var(--u) * 6) rgb(255 255 255 / 0.75));
}
.art[data-pop] {
  animation: pop 280ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
@keyframes pop {
  0% {
    scale: 0.6;
    filter: drop-shadow(0 0 calc(var(--u) * 10) #fff) brightness(1.6);
  }
  55% {
    scale: 1.16;
  }
}
.art[data-flip] {
  animation: flip 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes flip {
  from {
    opacity: 0;
    rotate: y 90deg;
  }
}

/* "60": arrows left, under the arrow. */
.ammo {
  position: absolute;
  left: 50%;
  top: calc(50% + var(--u) * 13);
  translate: -50% 0;
  font-size: calc(var(--u) * 20);
  font-weight: 400;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  filter: drop-shadow(0 calc(var(--u) * 2) calc(var(--u) * 3) var(--qs-shadow));
}
.ammo[data-shot] {
  animation: shot 220ms ease-out;
}
.ammo[data-empty] {
  color: var(--qs-warn);
}
@keyframes shot {
  40% {
    scale: 1.25;
    color: var(--qs-cream);
  }
}

.pad {
  position: absolute;
  left: 0;
  top: 0;
  filter: drop-shadow(0 calc(var(--u) * 2) calc(var(--u) * 3) var(--qs-shadow));
  pointer-events: none;
}
.padDot {
  position: absolute;
  width: calc(var(--u) * 12);
  height: calc(var(--u) * 12);
  translate: -50% -50%;
  transition: scale 120ms ease;
}
.padDot svg {
  display: block;
  width: 100%;
  height: 100%;
}
.padDot[data-dir='up'] {
  top: calc(var(--u) * -13);
}
.padDot[data-dir='down'] {
  top: calc(var(--u) * 13);
}
.padDot[data-dir='left'] {
  left: calc(var(--u) * -13.5);
}
.padDot[data-dir='right'] {
  left: calc(var(--u) * 13.5);
}
.padDot[data-lit] {
  scale: 1.25;
  filter: drop-shadow(0 0 calc(var(--u) * 4) #fff);
}

/* The d-pad's down: draws or puts away the bow. Its hit area covers the lower dot. */
.down {
  position: absolute;
  left: 0;
  top: calc(var(--u) * 13);
  width: max(24px, var(--u) * 26);
  height: max(24px, var(--u) * 26);
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
  translate: -50% -50%;
}

/* ---------- The key legend and the live region ---------- */

/* Which keys do what: small neutral chips under the element, in the page's own colours. */
.legend {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin: 6px 0 0;
  padding: 0 16px;
  font-size: 12px;
  line-height: 18px;
}
.legend span {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 9px 3px 4px;
  border: 1px solid var(--qs-chip-line);
  border-radius: 999px;
  background: var(--qs-chip);
  color: var(--qs-chip-ink);
  white-space: nowrap;
}
.legend kbd {
  display: inline-grid;
  place-items: center;
  min-width: 18px;
  height: 18px;
  padding: 0 3px;
  box-sizing: border-box;
  border: 1px solid var(--qs-chip-line);
  border-radius: 999px;
  background: var(--qs-chip-key);
  font: inherit;
  font-size: 11px;
}

/* A phone has no keys to learn: the HUD is the way in. */
@container (max-width: 520px) {
  .legend {
    display: none;
  }
}

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

@media (prefers-reduced-motion: reduce) {
  .row,
  .row[data-closing],
  .tile,
  .tile[data-flood] .blue,
  .tile[data-current] .item,
  .label,
  .art[data-pop],
  .art[data-flip],
  .ammo[data-shot] {
    animation: none;
  }
  .row[data-closing] {
    opacity: 0;
  }
  .strip,
  .focus,
  .art,
  .padDot,
  .window {
    transition: none;
  }
}
```

### quick-selector/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 };
}
```