# Controller glyphs

> Every controller input a game names in its prompts, as one reusable glyph that dips, lights and teaches.

The badges a game uses to say which input to use: face buttons, plus and minus, shoulder tabs, the d-pad, the direction buttons and both sticks with arrows for tilt, press and rotate, alone or with words beside them. Each is one Glyph with sizes, a press that dips and springs back with a flash, a glow, and looping hints. Press them, play the sheet with the keyboard or a gamepad, or watch a short run of play.

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

Category: Games · Controls.

## Anatomy

1. **Face badge**: A round tan badge 32 units across in a 40-unit box, the letter cut out of it by a mask. Plus and minus are smaller badges with the sign cut out.
2. **Prompt words**: `label` beside the glyph, 0.6 of its size, semibold in a warm white on a dark pill of its own; `labelPosition` puts it left, right or below.
3. **Shoulder tab**: L and R are tabs slanted on their outer side; ZL and ZR are triggers whose top rises to the outer corner. The right ones are mirror images.
4. **Edge**: The same shape again behind the tan, filled and stroked near-black 1.2 units out all round: a crisp dark outline that shows through the cut letters, keeps a glow out of them, and makes the glyph read on a light stage and a dark one.
5. **D-pad cross**: A cross with arms of 10.5 units; `direction` cuts a square out of the arm it names.
6. **Stick cap**: The stick seen from the side: an oval cap with a rim and a thin groove, its letter (L or R) cut out. When pressed it leans the way it is pushed.
7. **Foot**: The short stem under the cap; arrows that cross it are cut around it.
8. **Arrow**: How to move the stick, set by `motion`: tilt arrows beside it, a press arrow on top, up and down arrows, or two swinging rotate arrows. The box widens to hold them.

## When to use

- Telling a player which input does what, in a prompt over the game: “A Jump”, “ZL Aim”, “press the left stick to crouch”.
- A tutorial that teaches a move: a stick glyph with its arrows (tilt, press, rotate) and `animate` looping the hint until it is done.
- Showing that an input was taken: `pressed` dips the glyph and flashes it while the button is held, `lit` keeps it glowing.
- A controls screen, a pause menu or a help sheet that lists every input of a pad in one consistent drawing.
- Prompts drawn over anything: every shape carries its own dark edge and every label its own dark plate, so they read on sky, snow, a white menu or a black one.

## When not to use

- Keyboard shortcuts in an app or on a website. Use key caps (a kbd element) with the platform’s own keys.
- Clickable actions in a web UI. Use a button with words; a glyph alone names an input, it is not a control.
- Another pad’s own buttons (a cross, circle, square and triangle layout). These glyphs draw one family of pad; draw that pad’s set instead.
- A whole action bar with cooldowns and charges. Use a quick selector or ability icons and put a small glyph on each slot.
- Inside running text at body size. Under about 20 px the edge and the arrows close up; set the input in words, or use the glyph at sm or larger beside the line.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/controller-glyphs.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 { Glyph } from './controller-glyphs';

export function InteractPrompt({ held }: { held: boolean }) {
  return (
    <p>
      <Glyph name="A" size="lg" pressed={held} label="Open" labelPosition="left" />
      <Glyph name="LS" motion="horizontal" animate label="Steer" />
      <Glyph name="dpad" direction="up" size="md" lit />
    </p>
  );
}
```

## States

- **At rest**: The face buttons and plus and minus, tan inside their dark edge: what a prompt shows by default.
- **Pressed**: `pressed`: dipped to 0.86 with a flash of light. A pressed stick does not dip; its cap leans the way of its motion, or `lean`.
- **Lit**: `lit`: a glow behind the glyph and its words, for the input the game wants noticed: a warm white on a dark stage, amber on a light one, so it shows on both.
- **Hint looping**: `animate`: tilt arrows nudge out, the press arrow bobs into the cap, a face button breathes. It loops by design, and stops under reduced motion.
- **Stick motions**: One stick, every `motion`: none, right, left, either way, press, down, up, up or down, rotate.
- **Directions**: The d-pad and the four direction buttons, plain and with one `direction` marked (or `all` buttons filled).
- **Prompts**: `label` with `labelPosition` left, right and below, as a game lays its prompts out.
- **Sizes**: `size` sm 24, md 32, lg 40 (the kit’s own) and xl 56; any number of px works too.
- **Sheet, light**: `ControllerGlyphs` with `autoplay={false}` and `colorScheme="light"`: every glyph in groups on a white stage, the last input at the foot.
- **Sheet, dark**: `colorScheme="dark"`: only the stage and its captions turn dark; the glyphs keep their tan and their edge, as a game’s prompts do.

## How it works

- Every glyph is an SVG on one grid, where a face button’s box is 40 units: a round badge of 32 (27 for plus and minus), a tab slanted on its outer side, a trigger whose top rises to the outer corner, a cross of 32.5 with arms of 10.5, four direction buttons of 10.5 in a diamond, and a stick drawn from the side, an oval cap with a rim and a thin groove on a short foot.
- Letters and marks are not painted: an SVG mask cuts them out of the tan shape, over a dark edge drawn behind (the same shape filled and stroked near-black 1.2 units out all round), so every glyph carries its own crisp outline and reads on a light stage and a dark one alike. Arrows laid over a stick are cut around in the same way, so a press arrow sits in a notch of the cap.
- The SVG is sized in em of --glyph-size, the box of a face button, so size sets one number: sm 24, md 32, lg 40 (the kit’s own), xl 56 or any px; a label beside, before or under it is 0.6 of it, semibold, on a dark pill of its own.
- pressed dips the glyph to 0.86 in 70 ms and springs it back over 340 ms with an overshoot (a cubic-bezier past 1); a ring of light opens and fades behind it on each press. A pushed stick’s cap leans 2.6 units the way it goes, or sinks when pressed in, and its arrows nudge out.
- lit adds a glow behind the sharp shapes (two drop-shadows), warm white on a dark stage and amber on a light one; the opaque edge keeps it out of the cut letters. animate loops the hint: tilt arrows nudge out, the press arrow bobs into a squashed cap, the rotate arrows swing, a marked direction blinks.
- On the sheet, inputs are ids (A, dpad-up, LS-left…) and one input presses every glyph that names it: the plain stick, its tilt and its either-way glyphs, and a prompt. Pointer, Enter and Space press a glyph while held; with focus on the sheet the keyboard plays a pad (A B X Y, Q E, 1 3, + −, arrows, Shift + arrows); a gamepad is read each frame once it shows up.
- A demo plays a short run of play while it is on screen and untouched (walk, jump, aim and throw, quick items, crouch, look around, menu), with the last input as a prompt at the foot; any input hands it to the reader. The stage is plain, white or near-black through light-dark() tokens; the glyphs keep their colours on both.

## API reference

- **size** (`'sm' | 'md' | 'lg' | 'xl' | number`): On both. Glyph: the box of a face button in px, sm 24, md 32, lg 40 (the kit’s), xl 56; without it, --glyph-size (40 px). ControllerGlyphs: the size of every glyph on the sheet, by default 48 px on a wide stage, 40 under 760 px, then 32 and 26.
- **gamepad** (`boolean`, default `true`): ControllerGlyphs: reads a connected gamepad (it shows up after its first press), each frame while the sheet is on screen.
- **onInput** (`(input: string) => void`): ControllerGlyphs: called with each input the reader presses ('A', 'dpad-up', 'LS-left'…).
- **autoplay** (`boolean`, default `true`): ControllerGlyphs: while on screen and untouched, a short run of play on the sheet.
- **colorScheme** (`'light' | 'dark' | 'system'`): ControllerGlyphs: the stage’s colours, light, dark or the reader’s system setting. Without it the component follows the page’s color-scheme.
- **className** (`string`): Extra class for the root (on both components).
- **name** (`'A' | 'B' | 'X' | 'Y' | 'plus' | 'minus' | 'L' | 'R' | 'ZL' | 'ZR' | 'dpad' | 'buttons' | 'LS' | 'RS'`): Glyph: which input it names. Required.
- **direction** (`'up' | 'down' | 'left' | 'right' | 'all'`): Glyph: for the d-pad and the direction buttons, the one to mark (or all buttons).
- **motion** (`'none' | 'left' | 'right' | 'horizontal' | 'press' | 'up' | 'down' | 'vertical' | 'rotate'`, default `'none'`): Glyph: for a stick, the arrows that say how to move it.
- **pressed** (`boolean`, default `false`): Glyph: held down; it dips and lights, and springs back when released.
- **lean** (`'up' | 'down' | 'left' | 'right'`): Glyph: the way a pressed stick’s cap leans; by default its motion’s.
- **lit** (`boolean`, default `false`): Glyph: glowing, for the input the game wants noticed.
- **animate** (`boolean`, default `false`): Glyph: loops its hint (arrows nudge, the press arrow bobs, a marked direction blinks).
- **label** (`ReactNode`): Glyph: words beside it, as in a prompt.
- **labelPosition** (`'left' | 'right' | 'below'`, default `'right'`): Glyph: where the words go.
- **title** (`string`): Glyph: its accessible name; by default “A button”, “Left stick, tilt left”…

## Accessibility

- Each glyph is an image with a spoken name (“A button”, “D-pad up”, “Right stick, rotate”); its SVG is hidden, and a label beside it is real text.
- On the sheet, every glyph is a button in a named group per section, reached with Tab, pressed with Enter or Space while held, with a visible focus ring; a focused or hovered glyph loops its hint.
- The keyboard plays the pad only while focus is on the sheet, so the page’s own keys are never taken; the keys are listed in How it works.
- A polite live region says each input the reader presses and its action (“A button, Jump”), never the demo’s.
- Under reduced motion presses and leans change at once, nothing loops, flashes or blinks, and the demo never plays.

## Source

### controller-glyphs/controller-glyphs.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useState,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import {
  Glyph,
  describeGlyph,
  type GlyphDirection,
  type GlyphName,
  type GlyphSize,
  type StickMotion,
} from './glyph';
import { useAutoplay } from './use-autoplay';
import styles from './controller-glyphs.module.css';

/*
 * Controller glyphs: the full set of controller inputs a game names in its prompts, laid out as
 * a sheet on a plain stage, every glyph live. Each glyph carries its own dark edge and each label
 * its own dark plate, so the sheet reads on a light stage and a dark one with no scene behind it.
 *
 * - The glyphs are <Glyph /> (glyph.tsx), exported to use alone: face buttons, plus and minus,
 *   shoulder tabs, the d-pad, the direction buttons, both sticks with their arrows, and prompts
 *   with words beside them.
 * - Inputs are ids ('A', 'dpad-up', 'LS-left'...). One press lights every glyph on the sheet
 *   that names it: pushing the left stick left dips the plain stick (its cap leaning left), the
 *   "tilt left" and the "tilt left or right" glyphs, and a prompt that uses it.
 * - The reader presses glyphs with the pointer, or Enter and Space on a focused one; with focus
 *   on the sheet, the keyboard plays a pad (A B X Y, Q E for L R, 1 3 for ZL ZR, + and −, arrows
 *   for the d-pad, Shift + arrows for the left stick); a gamepad, once it wakes up with a press,
 *   is read each frame while the sheet is on screen.
 * - The last input shows at the foot as a prompt, with the action it does in the demo's game.
 * - While on screen and untouched, a demo plays a short run of play: walk, jump, aim and throw,
 *   a quick item, crouch, look around, open the menu.
 */

type Item = {
  id: string;
  name: GlyphName;
  direction?: GlyphDirection | 'all';
  motion?: StickMotion;
};

const STICK_MOTIONS: StickMotion[] = [
  'none',
  'right',
  'left',
  'horizontal',
  'press',
  'down',
  'up',
  'vertical',
  'rotate',
];
const DIRS: GlyphDirection[] = ['up', 'down', 'left', 'right'];

const GROUPS: { title: string; wide?: boolean; items: Item[] }[] = [
  {
    title: 'Face buttons',
    items: (['A', 'B', 'X', 'Y', 'plus', 'minus'] as GlyphName[]).map((name) => ({
      id: name,
      name,
    })),
  },
  {
    title: 'Shoulders',
    items: (['L', 'R', 'ZL', 'ZR'] as GlyphName[]).map((name) => ({ id: name, name })),
  },
  {
    title: 'Directions',
    wide: true,
    items: [
      { id: 'buttons-all', name: 'buttons', direction: 'all' },
      ...DIRS.map((d): Item => ({ id: `buttons-${d}`, name: 'buttons', direction: d })),
      { id: 'dpad-none', name: 'dpad' },
      ...DIRS.map((d): Item => ({ id: `dpad-${d}`, name: 'dpad', direction: d })),
    ],
  },
  ...(['RS', 'LS'] as const).map((stick) => ({
    title: stick === 'RS' ? 'Right stick' : 'Left stick',
    wide: true,
    items: STICK_MOTIONS.map((motion): Item => ({ id: `${stick}-${motion}`, name: stick, motion })),
  })),
];

/** The prompts row: a glyph with words, as the game shows them. */
const PROMPTS: (Item & { label: string; position: 'left' | 'right' | 'below' })[] = [
  { id: 'prompt-A', name: 'A', label: 'Jump', position: 'left' },
  { id: 'prompt-ZL', name: 'ZL', label: 'Aim', position: 'right' },
  { id: 'prompt-ZR', name: 'ZR', label: 'Throw', position: 'below' },
  { id: 'prompt-LS', name: 'LS', motion: 'press', label: 'Crouch', position: 'left' },
];

/** What each input does in the demo's game, for the readout at the foot. */
const ACTIONS: Record<string, string> = {
  A: 'Jump',
  B: 'Cancel',
  X: 'Climb',
  Y: 'Sort',
  plus: 'Menu',
  minus: 'Map',
  L: 'Ability',
  R: 'Throw',
  ZL: 'Aim',
  ZR: 'Throw',
  'dpad-up': 'Quick item',
  'dpad-down': 'Whistle',
  'dpad-left': 'Shield',
  'dpad-right': 'Weapon',
  'LS-left': 'Walk',
  'LS-right': 'Walk',
  'LS-up': 'Walk',
  'LS-down': 'Walk',
  'LS-press': 'Crouch',
  'RS-left': 'Look',
  'RS-right': 'Look',
  'RS-up': 'Look',
  'RS-down': 'Look',
  'RS-press': 'Zoom',
  'RS-rotate': 'Look around',
};

/** An input, and the ids of every glyph on the sheet that names it. */
function targets(input: string): string[] {
  const [head, tail] = input.split('-');
  if (head === 'dpad' && tail)
    return [`dpad-${tail}`, `buttons-${tail}`, 'dpad-none', 'buttons-all'];
  if ((head === 'LS' || head === 'RS') && tail) {
    const pair =
      tail === 'left' || tail === 'right'
        ? 'horizontal'
        : tail === 'up' || tail === 'down'
          ? 'vertical'
          : undefined;
    return [
      `${head}-none`,
      `${head}-${tail}`,
      ...(pair ? [`${head}-${pair}`] : []),
      ...PROMPTS.filter((p) => p.name === head && p.motion === tail).map((p) => p.id),
    ];
  }
  return [input, ...PROMPTS.filter((p) => p.name === input && !p.motion).map((p) => p.id)];
}

/** The input a glyph of the sheet presses: a stick's "left or right" tilts it right, the
 *  direction buttons are the d-pad's, a prompt presses its glyph's. */
function inputOf(item: Item): string {
  if (item.id.startsWith('prompt-')) return item.motion ? `${item.name}-${item.motion}` : item.name;
  if (item.name === 'buttons' && item.direction !== 'all') return `dpad-${item.direction}`;
  if (item.motion === 'horizontal') return `${item.name}-right`;
  if (item.motion === 'vertical') return `${item.name}-up`;
  return item.id;
}

/** The glyph of an input, for the readout. */
function glyphOf(input: string): Item {
  const [head, tail] = input.split('-');
  if (head === 'dpad') return { id: input, name: 'dpad', direction: tail as GlyphDirection };
  if (head === 'LS' || head === 'RS') return { id: input, name: head, motion: tail as StickMotion };
  return { id: input, name: head as GlyphName };
}

/** A run of play, as inputs held from `at` for `hold` ms. */
const DEMO: { at: number; input: string; hold: number }[] = [
  { at: 300, input: 'LS-right', hold: 1300 },
  { at: 1800, input: 'A', hold: 200 },
  { at: 2500, input: 'ZL', hold: 2300 },
  { at: 2900, input: 'RS-left', hold: 500 },
  { at: 3600, input: 'RS-right', hold: 450 },
  { at: 4300, input: 'ZR', hold: 220 },
  { at: 5300, input: 'dpad-up', hold: 220 },
  { at: 5900, input: 'dpad-right', hold: 220 },
  { at: 6600, input: 'LS-press', hold: 600 },
  { at: 7500, input: 'RS-rotate', hold: 1300 },
  { at: 9100, input: 'B', hold: 200 },
  { at: 9700, input: 'X', hold: 200 },
  { at: 10400, input: 'plus', hold: 260 },
];
const DEMO_LENGTH = 12000;

/** The keyboard as a pad, while focus is on the sheet. */
function keyInput(event: { code: string; key: string; shiftKey: boolean }): string | undefined {
  const arrow = event.code.startsWith('Arrow') ? event.code.slice(5).toLowerCase() : undefined;
  if (arrow) return event.shiftKey ? `LS-${arrow}` : `dpad-${arrow}`;
  const keys: Record<string, string> = {
    KeyA: 'A',
    KeyB: 'B',
    KeyX: 'X',
    KeyY: 'Y',
    KeyQ: 'L',
    KeyE: 'R',
    Digit1: 'ZL',
    Digit3: 'ZR',
    Minus: 'minus',
    NumpadSubtract: 'minus',
    Equal: 'plus',
    NumpadAdd: 'plus',
  };
  return keys[event.code];
}

/** A standard-mapping gamepad as inputs: its face buttons by position (right A, bottom B...). */
function padInputs(pad: Gamepad): string[] {
  const on: string[] = [];
  const names = ['B', 'A', 'Y', 'X', 'L', 'R', 'ZL', 'ZR', 'minus', 'plus', 'LS-press', 'RS-press'];
  names.forEach((name, index) => pad.buttons[index]?.pressed && on.push(name));
  (['up', 'down', 'left', 'right'] as const).forEach(
    (d, index) => pad.buttons[12 + index]?.pressed && on.push(`dpad-${d}`),
  );
  const stick = (prefix: string, x = 0, y = 0) => {
    if (Math.hypot(x, y) < 0.45) return;
    on.push(
      Math.abs(x) > Math.abs(y)
        ? `${prefix}-${x > 0 ? 'right' : 'left'}`
        : `${prefix}-${y > 0 ? 'down' : 'up'}`,
    );
  };
  stick('LS', pad.axes[0], pad.axes[1]);
  stick('RS', pad.axes[2], pad.axes[3]);
  return on;
}

export function ControllerGlyphs({
  size,
  gamepad = true,
  onInput,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The size of every glyph on the sheet; by default 48 px on a wide stage, 40 (the kit's), 32 or 26 on narrower ones. */
  size?: GlyphSize;
  /** Reads a connected gamepad (it shows up after its first press). */
  gamepad?: boolean;
  /** Called with each input the reader presses ('A', 'dpad-up', 'LS-left'...). */
  onInput?: (input: string) => void;
  /** While on screen and untouched, a short run of play on the sheet. */
  autoplay?: boolean;
  /** Light or dark stage, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, take } = useAutoplay<HTMLDivElement>(7000);
  const demo = autoplay && playing;
  /** The inputs held, by where they come from, so a key let go does not release the pad's. */
  const [held, setHeld] = useState<Record<string, string[]>>({});
  const [last, setLast] = useState<string>('A');
  const [message, setMessage] = useState('');
  /** The key under the mouse or the keyboard's focus, which loops its hint. */
  const [hint, setHint] = useState<string>();

  const hold = useCallback((source: string, inputs: string[]) => {
    setHeld((now) => {
      const before = now[source] ?? [];
      if (before.length === inputs.length && before.every((input, i) => input === inputs[i]))
        return now;
      return { ...now, [source]: inputs };
    });
  }, []);

  /** The reader pressed an input: it is theirs from now on, and it is announced. */
  const press = useEffectEvent((source: string, input: string) => {
    take();
    hold(source, [input]);
    setLast(input);
    const item = glyphOf(input);
    const action = ACTIONS[input];
    setMessage(
      `${describeGlyph(item.name, { direction: item.direction, motion: item.motion })}${action ? `, ${action}` : ''}`,
    );
    onInput?.(input);
  });

  const lit = new Set(Object.values(held).flat().flatMap(targets));

  // The demo: inputs held and let go on a loop, while it may play.
  useEffect(() => {
    if (!demo) return;
    let timers: number[] = [];
    const run = () => {
      timers = DEMO.flatMap(({ at, input, hold: ms }) => [
        window.setTimeout(() => {
          hold('demo', [input]);
          setLast(input);
        }, at),
        window.setTimeout(() => hold('demo', []), at + ms),
      ]);
    };
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    return () => {
      timers.forEach((timer) => window.clearTimeout(timer));
      window.clearInterval(loop);
      hold('demo', []);
    };
  }, [demo, hold]);

  // A gamepad, read each frame while the sheet is on screen and one is connected.
  const [pads, setPads] = useState(0);
  useEffect(() => {
    if (!gamepad || typeof navigator === 'undefined' || !navigator.getGamepads) return;
    const count = () => setPads(navigator.getGamepads().filter(Boolean).length);
    window.addEventListener('gamepadconnected', count);
    window.addEventListener('gamepaddisconnected', count);
    return () => {
      window.removeEventListener('gamepadconnected', count);
      window.removeEventListener('gamepaddisconnected', count);
    };
  }, [gamepad]);
  const readPad = useEffectEvent((inputs: string[]) => {
    const before = held.pad ?? [];
    const fresh = inputs.filter((input) => !before.includes(input));
    if (fresh.length) press('pad', fresh[0]);
    hold('pad', inputs);
  });
  useEffect(() => {
    if (!gamepad || !pads || !visible) return;
    let frame = 0;
    const tick = () => {
      const pad = navigator.getGamepads().find(Boolean);
      readPad(pad ? padInputs(pad) : []);
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [gamepad, pads, visible]);

  const onKeyDown = (event: KeyboardEvent) => {
    take();
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    const input = keyInput(event);
    if (!input) return;
    event.preventDefault();
    if (!event.repeat) press('keys', input);
  };
  const onKeyUp = (event: KeyboardEvent) => {
    if (keyInput(event)) hold('keys', []);
  };

  /** A glyph on the sheet: a button that presses its own input while held. */
  const key = (item: Item, label?: { text: string; position: 'left' | 'right' | 'below' }) => {
    const input = inputOf(item);
    const down = (event: PointerEvent) => {
      if (event.button !== 0) return;
      event.currentTarget.setPointerCapture?.(event.pointerId);
      press('pointer', input);
    };
    const up = () => hold('pointer', []);
    return (
      <button
        key={item.id}
        type="button"
        className={styles.key}
        data-part="key"
        onPointerDown={down}
        onPointerUp={up}
        onPointerCancel={up}
        onLostPointerCapture={up}
        onKeyDown={(event) => {
          if ((event.key === 'Enter' || event.key === ' ') && !event.repeat) {
            event.preventDefault();
            event.stopPropagation();
            press('button', input);
          }
        }}
        onKeyUp={(event) => {
          if (event.key === 'Enter' || event.key === ' ') {
            event.stopPropagation();
            hold('button', []);
          }
        }}
        onPointerEnter={(event) => event.pointerType === 'mouse' && setHint(item.id)}
        onPointerLeave={() => setHint((now) => (now === item.id ? undefined : now))}
        onFocus={(event) => event.currentTarget.matches(':focus-visible') && setHint(item.id)}
        onBlur={() => {
          hold('button', []);
          setHint((now) => (now === item.id ? undefined : now));
        }}
      >
        <Glyph
          name={item.name}
          direction={item.direction}
          motion={item.motion}
          size={size}
          pressed={lit.has(item.id)}
          animate={hint === item.id}
          label={label?.text}
          labelPosition={label?.position}
          className={styles.glyph}
        />
      </button>
    );
  };

  const readout = glyphOf(last);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-sized={size !== undefined || undefined}
      onPointerDown={take}
      onKeyDown={onKeyDown}
      onKeyUp={onKeyUp}
    >
      <div className={styles.sheet} role="group" aria-label="Controller glyphs" data-part="sheet">
        {GROUPS.map((group) => (
          <section
            key={group.title}
            className={styles.group}
            data-wide={group.wide || undefined}
            aria-label={group.title}
            data-part="group"
          >
            <h3 className={styles.title}>{group.title}</h3>
            <div className={styles.row}>{group.items.map((item) => key(item))}</div>
          </section>
        ))}
        <section className={styles.group} data-wide aria-label="Prompts" data-part="group">
          <h3 className={styles.title}>Prompts</h3>
          <div className={styles.row} data-prompts>
            {PROMPTS.map((item) => key(item, { text: item.label, position: item.position }))}
          </div>
        </section>
      </div>
      <div className={styles.readout} data-part="readout" aria-hidden="true">
        <Glyph
          key={last}
          name={readout.name}
          direction={readout.direction}
          motion={readout.motion}
          size="md"
          lit={lit.size > 0}
          label={ACTIONS[last] ?? describeGlyph(readout.name, readout)}
          labelPosition="right"
          className={styles.last}
        />
      </div>
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}

export { Glyph, describeGlyph } from './glyph';
export type { GlyphName, GlyphDirection, GlyphSize, StickMotion, GlyphProps } from './glyph';
```

### controller-glyphs/glyph.tsx

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

'use client';

import { useId, type CSSProperties, type ReactNode } from 'react';
import styles from './glyph.module.css';

/*
 * Glyph: one controller input as a game draws it in its prompts, flat tan inside a dark edge.
 *
 * - Each glyph is an SVG on a grid where a face button's box is 40 units: a round badge of 32, a
 *   slanted shoulder tab, a cross, four direction buttons, or a stick seen from the side (an oval
 *   cap with a rim, on a short foot), with arrows that say how to move it.
 * - The letter is not painted: it is cut out of the tan shape by a mask, over a dark edge drawn
 *   behind (the same shape, filled and stroked 2.4 units wider), so the glyph reads on a light
 *   stage and a dark one alike, as a game's prompts read on any scene. Arrows that overlap a
 *   stick are cut around in the same way, and get their own edge.
 * - Its size is the box's, in px: the SVG is drawn in em of it, so a 40 px glyph is the kit's
 *   own, and a label beside it is 0.6 of it (24 px).
 * - pressed dips it in 70 ms and springs it back with an overshoot on release, with a flash of
 *   light; a stick leans the way it is pushed. lit makes it glow. animate loops its hint: arrows
 *   nudge out, the press arrow bobs, the rotate arrows swing, a lit direction pulses.
 */

export type GlyphName =
  | 'A'
  | 'B'
  | 'X'
  | 'Y'
  | 'plus'
  | 'minus'
  | 'L'
  | 'R'
  | 'ZL'
  | 'ZR'
  | 'dpad'
  | 'buttons'
  | 'LS'
  | 'RS';
export type GlyphDirection = 'up' | 'down' | 'left' | 'right';
export type StickMotion =
  'none' | 'left' | 'right' | 'horizontal' | 'press' | 'up' | 'down' | 'vertical' | 'rotate';
export type GlyphSize = 'sm' | 'md' | 'lg' | 'xl' | number;

const SIZES = { sm: 24, md: 32, lg: 40, xl: 56 };

/** The glyph's name for a screen reader: "A button", "Left stick, tilt left". */
export function describeGlyph(
  name: GlyphName,
  { direction, motion = 'none' }: { direction?: GlyphDirection | 'all'; motion?: StickMotion } = {},
) {
  if (name === 'plus') return 'Plus button';
  if (name === 'minus') return 'Minus button';
  if (name === 'dpad') return direction && direction !== 'all' ? `D-pad ${direction}` : 'D-pad';
  if (name === 'buttons')
    return direction && direction !== 'all'
      ? `${direction[0].toUpperCase()}${direction.slice(1)} button`
      : 'Direction buttons';
  if (name === 'LS' || name === 'RS') {
    const stick = name === 'LS' ? 'Left stick' : 'Right stick';
    const how: Record<StickMotion, string> = {
      none: '',
      left: ', tilt left',
      right: ', tilt right',
      horizontal: ', tilt left or right',
      press: ', press',
      up: ', tilt up',
      down: ', tilt down',
      vertical: ', tilt up or down',
      rotate: ', rotate',
    };
    return stick + how[motion];
  }
  return `${name} button`;
}

/* ---------------------------------------------------------------------------------------------
 * The drawings. A glyph is one or more layers; each has a body (tan), holes cut out of the body
 * (letters, markers, the outline of an arrow laid over it) and an edge drawn behind (the body
 * itself unless a layer names another shape); `over` is tan drawn last, uncut (the arrows
 * themselves), with its own edge under everything.
 * ------------------------------------------------------------------------------------------- */

type Layer = { part: string; edge?: ReactNode; body: ReactNode; holes?: ReactNode };
type Art = { box: [number, number, number, number]; layers: Layer[]; over?: ReactNode };

const FONT = 'var(--glyph-font)';

/** An uppercase label centred on (x, y): its baseline sits 0.355 em under the centre. */
function letter(text: string, x: number, y: number, size: number, weight: number, spacing = 0) {
  return (
    <text
      x={x}
      y={y + size * 0.355}
      fontSize={size}
      fontWeight={weight}
      fontFamily={FONT}
      letterSpacing={spacing}
      textAnchor="middle"
      fill="#000"
    >
      {text}
    </text>
  );
}

const FACE = 'M20 4a16 16 0 1 0 0.001 0Z';
/** Plus and minus are smaller badges. */
const SMALL = 'M20 6.5a13.5 13.5 0 1 0 0.001 0Z';

function faceArt(name: GlyphName): Art {
  const small = name === 'plus' || name === 'minus';
  let holes: ReactNode;
  if (name === 'plus')
    holes = (
      <>
        <rect x="13.7" y="18.5" width="12.6" height="3" rx="0.4" fill="#000" />
        <rect x="18.5" y="13.7" width="3" height="12.6" rx="0.4" fill="#000" />
      </>
    );
  else if (name === 'minus')
    holes = <rect x="13.7" y="18.5" width="12.6" height="3" rx="0.4" fill="#000" />;
  else holes = letter(name, 20, 20, 20, 640);
  return {
    box: [0, 0, 40, 40],
    layers: [
      {
        part: 'face',
        body: <path d={small ? SMALL : FACE} />,
        holes,
      },
    ],
  };
}

/** The L tab, its left side slanted out; R is its mirror image. */
const TAB = 'M11 6H35Q37 6 37 8V32Q37 34 35 34H3.3Q1 34 1.5 31.7L6 10.1Q6.9 6 11 6Z';
/** The ZL tab, its top rising to the outer corner; ZR is its mirror image. */
const TRIGGER =
  'M3.5 13.4Q3.5 11.4 5.5 11L32.6 5.6Q36.5 4.8 36.5 8.8V32Q36.5 34 34.5 34H5.5Q3.5 34 3.5 32Z';

function shoulderArt(name: 'L' | 'R' | 'ZL' | 'ZR'): Art {
  const right = name === 'R' || name === 'ZR';
  const trigger = name.length === 2;
  const flip = right ? 'translate(40 0) scale(-1 1)' : undefined;
  const shape = <path d={trigger ? TRIGGER : TAB} transform={flip} />;
  const x = trigger ? (right ? 19.5 : 20.5) : right ? 19 : 21;
  return {
    box: [0, 0, 40, 40],
    layers: [
      {
        part: 'tab',
        body: shape,
        holes: trigger ? letter(name, x, 22, 18, 640, -0.6) : letter(name, x, 20.4, 20, 640),
      },
    ],
  };
}

const CROSS =
  'M16.25 3.75H23.75Q25.25 3.75 25.25 5.25V14.75H34.75Q36.25 14.75 36.25 16.25V23.75Q36.25 25.25 34.75 25.25H25.25V34.75Q25.25 36.25 23.75 36.25H16.25Q14.75 36.25 14.75 34.75V25.25H5.25Q3.75 25.25 3.75 23.75V16.25Q3.75 14.75 5.25 14.75H14.75V5.25Q14.75 3.75 16.25 3.75Z';
const TURN: Record<GlyphDirection, number> = { up: 0, right: 90, down: 180, left: 270 };

function dpadArt(direction?: GlyphDirection | 'all'): Art {
  const lit = direction && direction !== 'all' ? direction : undefined;
  return {
    box: [0, 0, 40, 40],
    layers: [
      {
        part: 'cross',
        body: <path d={CROSS} />,
        holes: lit && (
          <rect
            data-part="dir"
            x="16.5"
            y="5.5"
            width="7"
            height="7"
            rx="1"
            fill="#000"
            transform={`rotate(${TURN[lit]} 20 20)`}
          />
        ),
      },
    ],
  };
}

/** One direction button, drawn at the top and turned into place. */
function directionButtonArt(direction?: GlyphDirection | 'all'): Art {
  const all = !direction || direction === 'all';
  const dirs: GlyphDirection[] = ['up', 'right', 'down', 'left'];
  const ring =
    'M14.75 9.7a5.25 5.25 0 1 0 10.5 0a5.25 5.25 0 1 0 -10.5 0ZM16.4 9.7a3.6 3.6 0 1 0 7.2 0a3.6 3.6 0 1 0 -7.2 0Z';
  const dot = 'M14.75 9.7a5.25 5.25 0 1 0 10.5 0a5.25 5.25 0 1 0 -10.5 0Z';
  const on = (d: GlyphDirection) => all || d === direction;
  return {
    box: [0, 0, 40, 40],
    layers: [
      {
        part: 'buttons',
        body: dirs.map((d) => (
          <path
            key={d}
            d={on(d) ? dot : ring}
            fillRule="evenodd"
            transform={`rotate(${TURN[d]} 20 20)`}
          />
        )),
        holes: dirs
          .filter(on)
          .map((d) => (
            <path
              key={d}
              data-part={all ? undefined : 'dir'}
              d="M20 7.9L22.3 11.2H17.7Z"
              fill="#000"
              transform={`rotate(${TURN[d]} 20 20)`}
            />
          )),
      },
    ],
  };
}

/* The stick: an oval cap (outer rim, a thin dark groove, the top face) on a short foot. */
const CAP =
  'M5 20.15a18 14.5 0 1 0 36 0a18 14.5 0 1 0 -36 0Z' +
  'M7.6 19.12a15.4 11.86 0 1 0 30.8 0a15.4 11.86 0 1 0 -30.8 0Z' +
  'M9.08 18.67a13.92 10.37 0 1 0 27.84 0a13.92 10.37 0 1 0 -27.84 0Z';
const FOOT =
  'M17.87 35.57Q23 36.4 28.13 35.57V37.05Q28.13 38.9 26.4 39.12Q23 39.6 19.6 39.12Q17.87 38.9 17.87 37.05Z';
const TILT_RIGHT =
  'M42.3 10.4L52.8 18.9Q53.8 20.25 52.8 21.6L42.3 30.1Q41.6 30.5 41.9 29.8L44.1 24.9Q46.1 20.25 44.1 15.6L41.9 10.7Q41.6 10 42.3 10.4Z';
const MIRROR = 'translate(46 0) scale(-1 1)';
const PRESS = 'M20.1 -4.4H25.9V3.2H29.7L23 10.6L16.3 3.2H20.1Z';
const DOWN = 'M35.1 26.6H40.7V32.4H44.2L37.9 39.2L31.6 32.4H35.1Z';
const UP = 'M38.9 27.4L44.2 32.4H41.7V38.6H36.1V32.4H33.6Z';
const UP_TIP = 'M37.9 26.2L43.5 31.6H32.3Z';
const DOWN_TIP = 'M32.3 33.6H43.5L37.9 39.8Z';
const SWING_LEFT = 'M2.4 11.6Q-2 20 2.6 28.2';
const SWING_LEFT_HEAD = 'M4.8 31.6L-0.6 29.6L5.2 25.6Z';

function stickArt(name: 'LS' | 'RS', motion: StickMotion): Art {
  /** Each arrow: its paths, the way it moves, and whether it lies over the stick (cut around). */
  const arrows: { dir: string; paths: ReactNode; cut?: boolean }[] = [];
  const right = <path d={TILT_RIGHT} />;
  const left = <path d={TILT_RIGHT} transform={MIRROR} />;
  if (motion === 'right' || motion === 'horizontal') arrows.push({ dir: 'right', paths: right });
  if (motion === 'left' || motion === 'horizontal') arrows.push({ dir: 'left', paths: left });
  if (motion === 'press') arrows.push({ dir: 'press', paths: <path d={PRESS} />, cut: true });
  if (motion === 'down') arrows.push({ dir: 'down', paths: <path d={DOWN} />, cut: true });
  if (motion === 'up') arrows.push({ dir: 'up', paths: <path d={UP} />, cut: true });
  if (motion === 'vertical')
    arrows.push(
      { dir: 'up', paths: <path d={UP_TIP} />, cut: true },
      { dir: 'down', paths: <path d={DOWN_TIP} />, cut: true },
    );
  if (motion === 'rotate')
    arrows.push({
      dir: 'rotate',
      paths: (
        <>
          {[0, 180].map((turn) => (
            <g key={turn} transform={turn ? `rotate(180 23 20)` : undefined}>
              <path
                d={SWING_LEFT}
                data-stroke
                fill="none"
                stroke="currentColor"
                strokeWidth="2.6"
                strokeLinecap="round"
              />
              <path d={SWING_LEFT_HEAD} />
            </g>
          ))}
        </>
      ),
    });
  const cuts = arrows
    .filter((arrow) => arrow.cut)
    .map((arrow) => (
      <g key={arrow.dir} fill="#000" stroke="#000" strokeWidth="2.6" strokeLinejoin="round">
        {arrow.paths}
      </g>
    ));
  const box: Art['box'] =
    motion === 'right'
      ? [0, 0, 56, 46]
      : motion === 'left'
        ? [-10, 0, 56, 46]
        : motion === 'horizontal'
          ? [-10, 0, 66, 46]
          : motion === 'press'
            ? [0, -6, 46, 52]
            : motion === 'rotate'
              ? [-4, 0, 54, 46]
              : [0, 0, 46, 46];
  return {
    box,
    layers: [
      { part: 'foot', body: <path d={FOOT} />, holes: cuts },
      {
        part: 'cap',
        // The edge is the whole oval, so the groove between rim and face is dark on any stage.
        edge: <ellipse cx="23" cy="20.15" rx="18" ry="14.5" />,
        body: <path d={CAP} fillRule="evenodd" />,
        holes: (
          <>
            {letter(name[0], 23, 18.7, 20, 400)}
            {cuts}
          </>
        ),
      },
    ],
    over: arrows.map((arrow) => (
      <g key={arrow.dir} data-part="arrow" data-dir={arrow.dir}>
        {arrow.paths}
      </g>
    )),
  };
}

function art(name: GlyphName, direction?: GlyphDirection | 'all', motion: StickMotion = 'none') {
  if (name === 'LS' || name === 'RS') return stickArt(name, motion);
  if (name === 'dpad') return dpadArt(direction);
  if (name === 'buttons') return directionButtonArt(direction);
  if (name === 'L' || name === 'R' || name === 'ZL' || name === 'ZR') return shoulderArt(name);
  return faceArt(name);
}

export type GlyphProps = {
  /** Which input: a face button, plus or minus, a shoulder tab, the d-pad, the four direction buttons, or a stick. */
  name: GlyphName;
  /** For the d-pad and the direction buttons: the one to mark (or 'all' buttons). */
  direction?: GlyphDirection | 'all';
  /** For a stick: the arrows that say how to move it. */
  motion?: StickMotion;
  /** The box of a face button, in px: sm 24, md 32, lg 40 (the kit's), xl 56, or any number. */
  size?: GlyphSize;
  /** Held down: it dips and lights; on release it springs back. */
  pressed?: boolean;
  /** The way a pressed stick leans; by default its motion's. */
  lean?: GlyphDirection;
  /** Glowing, for the input the game wants noticed. */
  lit?: boolean;
  /** Loops its hint: arrows nudge, the press arrow bobs, a marked direction pulses. */
  animate?: boolean;
  /** Words beside it, as in a prompt ("Jump"). */
  label?: ReactNode;
  /** Where the words go. */
  labelPosition?: 'left' | 'right' | 'below';
  /** Its accessible name; by default "A button", "Left stick, tilt left"... */
  title?: string;
  className?: string;
  style?: CSSProperties;
};

const LEANS: Partial<Record<StickMotion, GlyphDirection>> = {
  left: 'left',
  right: 'right',
  up: 'up',
  down: 'down',
};

export function Glyph({
  name,
  direction,
  motion = 'none',
  size,
  pressed = false,
  lean,
  lit = false,
  animate = false,
  label,
  labelPosition = 'right',
  title,
  className = '',
  style,
}: GlyphProps) {
  const uid = useId().replace(/:/g, '');
  const drawing = art(name, direction, motion);
  const [x, y, w, h] = drawing.box;
  const stick = name === 'LS' || name === 'RS';
  const px = typeof size === 'number' ? size : size ? SIZES[size] : undefined;
  return (
    <span
      className={`${styles.glyph} ${className}`}
      data-glyph={name}
      data-motion={stick ? motion : undefined}
      data-pressed={pressed || undefined}
      data-lean={
        pressed && stick
          ? (lean ?? LEANS[motion] ?? (motion === 'press' ? 'press' : undefined))
          : undefined
      }
      data-lit={lit || undefined}
      data-animate={animate || undefined}
      data-label={label ? labelPosition : undefined}
      style={px ? ({ '--glyph-size': `${px}px`, ...style } as CSSProperties) : style}
    >
      <span
        className={styles.art}
        role="img"
        aria-label={title ?? describeGlyph(name, { direction, motion })}
      >
        <svg
          className={styles.svg}
          viewBox={`${x} ${y} ${w} ${h}`}
          style={{ width: `${w / 40}em`, height: `${h / 40}em` }}
          aria-hidden="true"
          focusable="false"
        >
          {drawing.over ? <g className={styles.edge}>{drawing.over}</g> : null}
          {drawing.layers.map((layer, index) => {
            const mask = `${uid}-${index}`;
            return (
              <g key={layer.part} data-part={layer.part}>
                {layer.holes ? (
                  <mask
                    id={mask}
                    maskUnits="userSpaceOnUse"
                    x="-20"
                    y="-20"
                    width="100"
                    height="100"
                  >
                    <rect x="-20" y="-20" width="100" height="100" fill="#fff" />
                    {layer.holes}
                  </mask>
                ) : null}
                <g className={styles.edge}>{layer.edge ?? layer.body}</g>
                <g className={styles.body} mask={layer.holes ? `url(#${mask})` : undefined}>
                  {layer.body}
                </g>
              </g>
            );
          })}
          {drawing.over ? <g className={styles.body}>{drawing.over}</g> : null}
        </svg>
      </span>
      {label ? (
        <span className={styles.label} data-part="label">
          {label}
        </span>
      ) : null}
    </span>
  );
}
```

### controller-glyphs/controller-glyphs.module.css

```css
/* Controller glyphs (controller-glyphs.tsx): a plain stage with the sheet of glyphs on it, in
   groups, centred, and the last input at its foot as a prompt. The glyphs are 48 px on a wide
   stage, 40 (the kit's) under 760 px, 32 under 560, 26 under 380 (a container query), or the size
   prop.

   Colours are the tokens below, light-dark(light, dark) where the two differ: only the stage and
   its captions change, white by day and near-black at night; the glyphs keep their tan inside a
   dark edge, and their words sit on dark plates, so they read on both, as a game's prompts do.

   Motion lives in glyph.module.css; here, only the keys' hover and focus. */

.root {
  /* The stage, its edge, and the captions over each group. */
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  --caption: rgb(23 25 29 / 0.55);
  /* The keys: hover wash and focus ring. */
  --key-hover: rgb(0 0 0 / 0.05);
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  justify-content: center;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #121214);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --caption: light-dark(rgb(23 25 29 / 0.55), rgb(236 236 239 / 0.55));
    --key-hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --focus: light-dark(#387bff, #6b9bff);
  }
}
/* 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;
}

.sheet {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
  gap: 22px 40px;
  box-sizing: border-box;
  width: 100%;
  max-width: 980px;
  margin: 0 auto;
  padding: 36px 40px 8px;
}
.root:not([data-sized]) .sheet {
  --glyph-size: 48px;
}
@container (max-width: 760px) {
  .root:not([data-sized]) .sheet {
    --glyph-size: 40px;
  }
}
@container (max-width: 560px) {
  .root:not([data-sized]) .sheet {
    --glyph-size: 32px;
  }
  .sheet {
    gap: 14px 20px;
    padding: 20px 16px 8px;
  }
}
@container (max-width: 380px) {
  .root:not([data-sized]) .sheet {
    --glyph-size: 26px;
  }
}

.group {
  min-width: 0;
}
.group[data-wide] {
  grid-column: 1 / -1;
}
.title {
  margin: 0 0 6px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--caption);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 6px;
}
.row[data-prompts] {
  gap: 8px 22px;
}

/* A key: the glyph as a button, with no chrome of its own. */
.key {
  display: inline-grid;
  place-items: center;
  min-width: 0;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
  transition: background-color 160ms ease-out;
}
@media (hover: hover) {
  .key:hover {
    background: var(--key-hover);
  }
}
.key:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
/* The last input, as a prompt at the stage's foot. */
.readout {
  display: flex;
  align-items: center;
  justify-content: center;
  /* A stick is taller than a button: a fixed height keeps the stage from moving. */
  height: 48px;
  box-sizing: content-box;
  padding: 10px 24px 28px;
}
.last {
  animation: enter 260ms cubic-bezier(0.3, 1.5, 0.5, 1);
}

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

@keyframes enter {
  from {
    opacity: 0;
    translate: 0.6em 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .last {
    animation: none;
  }
}
```

### controller-glyphs/glyph.module.css

```css
/* Glyph (glyph.tsx): one controller input. Its size is --glyph-size, the box of a face button
   (40 px, as the kit draws it); the SVG is sized in em of it, and a label is 0.6 of it.

   Colours: the tan of the shapes, the dark edge behind them that the letters are cut into, the
   label's warm white on its dark plate, and the glow. A game keeps its prompts' colours on any
   scene, so the shapes, the edge and the plate are the same on a light stage and a dark one; only
   the glow changes, a warm white on dark and an amber that shows on light.

   Crispness: the shapes are plain SVG fills and strokes, never blurred; a glow is a drop-shadow
   behind them, so the shapes themselves stay sharp at any pixel ratio.

   Motion: a press dips the art in 70 ms and springs it back with an overshoot (transitions of
   scale and translate), with a flash of light (a keyframe on a ring behind it); a pushed stick's
   cap leans the way it goes; animate loops the hints (keyframes on the arrows and marks). */

.glyph {
  --glyph-ink: #e2ded3;
  /* The dark edge round every shape, and the plate behind the words. */
  --glyph-edge: #1d1c19;
  --glyph-plate: rgb(29 28 25 / 0.86);
  --glyph-text: #efe8da;
  --glyph-glow: rgb(255 249 228 / 0.85);
  --glyph-glow-soft: rgb(255 240 200 / 0.4);
  --glyph-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  display: inline-flex;
  align-items: center;
  gap: 0.22em;
  font-size: var(--glyph-size, 40px);
  line-height: 1;
  vertical-align: middle;
}
@supports (color: light-dark(#fff, #000)) {
  .glyph {
    --glyph-glow: light-dark(rgb(255 168 28 / 0.95), rgb(255 249 228 / 0.85));
    --glyph-glow-soft: light-dark(rgb(255 176 40 / 0.5), rgb(255 240 200 / 0.4));
  }
}
.glyph[data-label='left'] {
  flex-direction: row-reverse;
}
.glyph[data-label='below'] {
  flex-direction: column;
  gap: 0.1em;
}

.art {
  position: relative;
  display: inline-grid;
  place-items: center;
}
/* The flash of a press: a soft ring of light that opens and fades behind the glyph. */
.art::before {
  content: '';
  position: absolute;
  inset: 8%;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--glyph-glow-soft), transparent);
  opacity: 0;
  pointer-events: none;
}
.glyph[data-pressed] .art::before {
  animation: flash 420ms cubic-bezier(0.2, 0.7, 0.3, 1);
}

.svg {
  display: block;
  overflow: visible;
  scale: 1;
  translate: 0 0;
  transition:
    scale 340ms cubic-bezier(0.3, 1.9, 0.5, 1),
    translate 340ms cubic-bezier(0.3, 1.9, 0.5, 1),
    filter 220ms ease-out;
}
.glyph[data-pressed] .svg {
  scale: 0.86;
  translate: 0 0.03em;
  filter: drop-shadow(0 0 0.1em var(--glyph-glow));
  transition-duration: 70ms, 70ms, 70ms;
  transition-timing-function: cubic-bezier(0.2, 0.8, 0.3, 1);
}
.glyph[data-lit] .svg {
  filter: drop-shadow(0 0 0.08em var(--glyph-glow)) drop-shadow(0 0 0.24em var(--glyph-glow-soft));
}
.glyph[data-pressed][data-glyph='LS'] .svg,
.glyph[data-pressed][data-glyph='RS'] .svg {
  scale: 0.94;
  translate: 0 0;
}

/* The edge: each shape again behind itself, filled and stroked dark 1.2 units out all round. It
   shows through the cut letters too, and keeps a glow out of them. A stroked arrow (the rotate
   arrows' arcs) keeps its fill off and its stroke widened by the same. */
.edge,
.edge * {
  fill: var(--glyph-edge);
  stroke: var(--glyph-edge);
  stroke-width: 2.4px;
  stroke-linejoin: round;
}
.edge [data-stroke] {
  fill: none;
  stroke-width: 5px;
  stroke-linecap: round;
}
.body {
  color: var(--glyph-ink);
  fill: var(--glyph-ink);
}

/* A pushed stick: its cap leans the way it goes (SVG user units), pressed in it sinks. */
.glyph [data-part='cap'] {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  transition:
    translate 340ms cubic-bezier(0.3, 1.7, 0.5, 1),
    scale 340ms cubic-bezier(0.3, 1.7, 0.5, 1);
}
.glyph[data-lean] [data-part='cap'] {
  transition-duration: 90ms;
  transition-timing-function: cubic-bezier(0.2, 0.8, 0.3, 1);
}
.glyph[data-lean='left'] [data-part='cap'] {
  translate: -2.6px 0;
}
.glyph[data-lean='right'] [data-part='cap'] {
  translate: 2.6px 0;
}
.glyph[data-lean='up'] [data-part='cap'] {
  translate: 0 -2px;
}
.glyph[data-lean='down'] [data-part='cap'] {
  translate: 0 2px;
}
.glyph[data-lean='press'] [data-part='cap'] {
  translate: 0 2.2px;
  scale: 1 0.9;
}

/* The arrows: still, nudged out once while pressed, looping while animate is on. */
.glyph [data-part='arrow'] {
  transition: translate 300ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.glyph[data-pressed] [data-part='arrow'][data-dir='right'] {
  translate: 2.4px 0;
}
.glyph[data-pressed] [data-part='arrow'][data-dir='left'] {
  translate: -2.4px 0;
}
.glyph[data-pressed] [data-part='arrow'][data-dir='press'],
.glyph[data-pressed] [data-part='arrow'][data-dir='down'] {
  translate: 0 2.2px;
}
.glyph[data-pressed] [data-part='arrow'][data-dir='up'] {
  translate: 0 -2.2px;
}
.glyph[data-motion] [data-part='arrow'][data-dir='rotate'] {
  transform-origin: 23px 20px;
  transform-box: view-box;
}

.glyph[data-animate] [data-part='arrow'][data-dir='right'] {
  animation: nudge-right 1.2s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.glyph[data-animate] [data-part='arrow'][data-dir='left'] {
  animation: nudge-left 1.2s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.glyph[data-animate] [data-part='arrow'][data-dir='down'] {
  animation: nudge-down 1.2s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.glyph[data-animate] [data-part='arrow'][data-dir='up'] {
  animation: nudge-up 1.2s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.glyph[data-animate] [data-part='arrow'][data-dir='press'] {
  animation: nudge-down 0.9s cubic-bezier(0.5, 0, 0.3, 1) infinite;
}
.glyph[data-animate][data-motion='press'] [data-part='cap'] {
  animation: squash 0.9s cubic-bezier(0.5, 0, 0.3, 1) infinite;
}
.glyph[data-animate] [data-part='arrow'][data-dir='rotate'] {
  animation: swing 1.6s cubic-bezier(0.45, 0, 0.3, 1) infinite;
}
.glyph[data-animate] [data-part='dir'] {
  transform-box: fill-box;
  transform-origin: center;
  animation: blink 1s steps(1, end) infinite;
}
.glyph[data-animate]:not(
    [data-glyph='LS'],
    [data-glyph='RS'],
    [data-glyph='dpad'],
    [data-glyph='buttons']
  )
  .svg {
  animation: breathe 1.4s ease-in-out infinite;
}

.label {
  font-family: var(--glyph-font);
  font-size: 0.6em;
  font-weight: 600;
  line-height: 1.17;
  color: var(--glyph-text);
  white-space: nowrap;
  /* A plate of its own, so the words read on a light stage as on a dark one. */
  padding: 0.12em 0.6em 0.16em;
  border-radius: 999px;
  background: var(--glyph-plate);
  text-shadow: 0 0 0 transparent;
  transition: text-shadow 220ms ease-out;
}
.glyph[data-lit] .label,
.glyph[data-pressed] .label {
  text-shadow:
    0 0 0.2em var(--glyph-glow),
    0 0 0.5em var(--glyph-glow-soft);
}

@keyframes flash {
  0% {
    opacity: 1;
    scale: 0.7;
  }
  100% {
    opacity: 0;
    scale: 1.6;
  }
}
@keyframes nudge-right {
  0%,
  100% {
    translate: 0 0;
  }
  45% {
    translate: 2.6px 0;
  }
}
@keyframes nudge-left {
  0%,
  100% {
    translate: 0 0;
  }
  45% {
    translate: -2.6px 0;
  }
}
@keyframes nudge-down {
  0%,
  100% {
    translate: 0 0;
  }
  45% {
    translate: 0 2.4px;
  }
}
@keyframes nudge-up {
  0%,
  100% {
    translate: 0 0;
  }
  45% {
    translate: 0 -2.4px;
  }
}
@keyframes squash {
  0%,
  30%,
  100% {
    scale: 1;
  }
  50% {
    scale: 1 0.9;
  }
}
@keyframes swing {
  0%,
  100% {
    rotate: -14deg;
  }
  50% {
    rotate: 14deg;
  }
}
@keyframes blink {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0;
  }
}
@keyframes breathe {
  0%,
  100% {
    filter: drop-shadow(0 0 0 transparent);
  }
  50% {
    filter: drop-shadow(0 0 0.12em var(--glyph-glow));
  }
}

@media (prefers-reduced-motion: reduce) {
  .svg,
  .glyph [data-part='cap'],
  .glyph [data-part='arrow'] {
    transition: none;
  }
  .glyph .art::before,
  .glyph[data-animate] .svg,
  .glyph[data-animate] [data-part] {
    animation: none;
  }
}
```

### controller-glyphs/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 };
}
```