# Ability icons

> A game HUD’s ability kit: bold coloured glyphs, an equipped slot with a 48-tick cooldown ring, and a ready flash.

The tool abilities of an adventure game, each a flat glyph in its own colour so the player knows it before its shape. Equip one from the strip and use it: the slot’s ring goes dark and lights again tick by tick, clockwise; trying too early shakes the slot, and the last tick flashes it ready. Upgraded abilities wear a cream “+”.

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

Category: Games · HUD.

## Anatomy

1. **Strip**: A dark warm plate across the top, one 80-unit cell per ability, in the order of `abilities`. It is a radiogroup: one Tab stop, then the arrow keys.
2. **Equipped frame**: A cream frame that springs in round the equipped ability, its glyph a touch larger and glowing.
3. **Strip ring**: A small ring of ticks round a cooling ability in the strip, lit clockwise as it cools, so it reads even while another one is equipped.
4. **Upgrade badge**: A cream “+” in the glyph’s lower right corner, cut out of the shape with a moat so it reads on any colour.
5. **Name**: The equipped ability’s name under the strip on a pill of the same plate, with “+” when upgraded; it rises in on each change.
6. **Cooldown ring**: The equipped slot’s ring of 48 ticks: all dark when the ability is used, lit one by one from 12 o’clock over the cooldown, then a white flash.
7. **Slot glyph**: The equipped glyph at the ring’s centre, faded while it cools. Using it kicks it; using it too early shakes the slot.
8. **Use button**: A pill with the L key that fires the equipped ability. While it cools it is `aria-disabled` but still answers, with the time left.

## When to use

- A game HUD with a small kit of abilities, one equipped at a time, that the player swaps between and fires.
- Abilities with cooldowns, where the player has to read without looking whether the equipped one can be used again.
- A kit whose pieces are told apart by colour first and by shape second: each glyph keeps one colour on any backdrop.
- Showing that some abilities have been upgraded, with a badge that pops in when the upgrade lands.
- Choosing the ability with a d-pad, the arrow keys or a tap, and firing it from a button or Enter.

## When not to use

- An inventory of many items with counts. Use a grid of slots, or a quick selector wheel for a handful.
- A single timed effect with no choice to make. Use an effect timer.
- A resource that drains and refills while it is used, like stamina or heat. Use a stamina wheel or a heat gauge.
- A toolbar in an app. Use a segmented control or icon buttons with tooltips; the dark HUD plates belong in a game.
- Showing which button fires what. Use button hints or controller glyphs.

## Installation

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

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

export function Hud() {
  return (
    <AbilityIcons
      defaultSelected="lock"
      upgraded={['round-bomb', 'lock']}
      cooldowns={{ lock: 12 }}
      onUse={(ability) => console.log('used', ability)}
      autoplay={false}
    />
  );
}
```

## States

- **Ready**: The round bomb equipped, every tick of its ring lit: it can be used now.
- **Cooling**: `defaultCooling={{ "round-bomb": 0.4 }}`: the ring is lit two fifths of the way round, the glyph faded, Use dimmed.
- **Cooling in the strip**: The motorbike equipped and ready while the bombs and the time lock cool: their small rings show it in the strip.
- **No cooldown**: The magnet equipped: it has no cooldown (nor has the camera, and `cooldowns` sets any to 0), so its ring stays lit and Use always fires.
- **All upgraded**: `upgraded` lists all seven: every glyph wears the cream “+”, and the name says so.
- **No upgrades**: `upgraded={[]}`: the bare glyphs.
- **Short kit**: `abilities` with four of the seven: the strip shrinks round them; the slot stays centred under it.
- **Light stage**: `colorScheme="light"`, or a light page: the white stage; the dark plates cast a soft shadow so they sit on it.
- **Dark stage**: `colorScheme="dark"`, or a dark page: the near-black stage; the plates, glyphs and ticks keep their colours.

## How it works

- Seven glyphs (round bomb, square bomb, magnet, time lock, ice block, camera, motorbike) are flat SVG silhouettes on a 54-unit square. Their details are cut out with a mask, never painted on, so the plate shows through them. An upgrade adds a cream “+” with a 2.5-unit moat cut around it.
- The equipped slot sits in a ring of 48 ticks, 3 units wide from radius 33 to 50 of a 100-unit box. Using an ability with a cooldown darkens every tick at once; they light again one by one from 12 o’clock, clockwise.
- Cooldowns are kept as a start time and a length, so they run on the clock, not on frames. A requestAnimationFrame loop only turns the elapsed time into a tick count, runs while something cools and the HUD is on screen, and sets state only when a count changes: 48 times per cooldown.
- The strip shows the same progress for every cooling ability in a small ring of its own: the same 48 ticks in a thinner SVG band, lit by the same count.
- The moments a player feels are Web Animations on transforms, opacity and filters: the use kick (a squash, then a punch), the shake of an ability used too early, the white flash and swell of the ring when it is ready again, the pop of a “+”. Under reduced motion they are skipped; the ring still counts.
- While on screen and untouched, the HUD plays a round of play: bombs thrown, one tried too early, abilities equipped by walking the strip like a d-pad, an upgrade. Any input of the reader’s ends it.
- The HUD sits on its own, centred on a plain stage (white, or near-black at night: light-dark() tokens following the page’s color-scheme or the colorScheme prop). Like a game’s HUD it brings its own dark plates, behind the strip, the name, the slot and the Use button, so the glyphs, the cream words and the ring’s dark ticks read the same on either; on the light stage the plates cast a soft shadow.
- Everything is vector and drawn at the screen’s resolution: SVG glyphs and tick rings, CSS plates. No shape is blurred or scaled as a bitmap; glows only ever sit behind the sharp shapes.

## API reference

- **abilities** (`AbilityId[]`, default `all seven`): The abilities the strip lists, in order: 'round-bomb', 'square-bomb', 'magnet', 'lock', 'ice', 'camera', 'bike'.
- **defaultSelected** (`AbilityId`, default `the first`): The ability equipped at first.
- **selected** (`AbilityId`): The equipped ability, controlled; pair it with onSelectedChange.
- **onSelectedChange** (`(ability: AbilityId) => void`): Called when another ability is equipped.
- **upgraded** (`AbilityId[]`, default `['round-bomb', 'square-bomb', 'lock']`): The abilities that wear the “+” of an upgrade.
- **cooldowns** (`Partial<Record<AbilityId, number>>`): Cooldowns in seconds, overriding each ability’s own (bombs 4, lock 8, ice 3, bike 6, magnet and camera none); 0 for none.
- **onUse** (`(ability: AbilityId) => void`): Called when the equipped ability is used; not when it is refused because it is still cooling.
- **defaultCooling** (`Partial<Record<AbilityId, number>>`): Abilities already cooling at first, each with the share of its cooldown that has passed (0 to 1); the ring counts on from there. Turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the HUD plays a short round of play. Any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): A light or dark stage, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Added to the stage.

## Accessibility

- The strip is a radiogroup: one Tab stop, the arrow keys, Home and End equip an ability, and Enter uses the equipped one. Each radio is named with its ability, its upgrade and whether it is cooling.
- Use is a real button named with the ability it fires; while that ability cools it is marked aria-disabled but still answers, with the shake and the time left.
- A polite live region says what happens: equipped, used and ready in how long, not ready yet, ready, upgraded.
- The glyphs, the slot and its ring are decorative (aria-hidden): every fact they show is in the names and the live region. The exported AbilityGlyph takes a title when a glyph should be announced on its own.
- Reduced motion turns off the demo and every kick, shake, flash and pop; the rings keep counting. Focus is a cream ring on the strip and the button.

## Source

### ability-icons/ability-icons.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { ABILITIES, ABILITY_IDS, AbilityGlyph, type AbilityId } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './ability-icons.module.css';

export { AbilityGlyph, ABILITIES, ABILITY_IDS, type AbilityId } from './glyphs';

/*
 * Ability icons: the tool abilities of a game HUD, shown on their own on a plain stage. A strip
 * lists the kit, one glyph per ability in its own colour; the equipped one also sits in a slot
 * inside a ring of 48 ticks, which is how the player reads, without looking, whether it can be
 * used again. Like a game's HUD it brings its own dark plates, so it reads on any backdrop.
 *
 * - Using an ability kicks its glyph (a squash, then a punch) and, if it has a cooldown, darkens
 *   every tick at once. The ticks light again one by one, clockwise from 12 o'clock, over the
 *   cooldown; the strip shows the same progress in a small ring, so a cooling ability can be told
 *   apart even when another is equipped.
 * - When the last tick lights, the ring flashes white and swells, and the glyph glows: ready.
 *   Using it before then shakes the slot instead.
 * - An upgraded ability wears a cream "+"; it pops in when the upgrade lands.
 * - Cooldowns run on the clock (performance.now), not on frames: a requestAnimationFrame loop only
 *   turns the time into a tick count, runs while something cools and the HUD is on screen, and
 *   sets state only when a count changes, 48 times per cooldown.
 * - The kicks, shakes and flashes are Web Animations on transforms, opacity and filters, skipped
 *   under reduced motion; the ring still counts.
 * - While on screen and untouched, the HUD plays a short round of play: equipping, throwing bombs,
 *   trying one too early, an upgrade. Any input of the reader's ends it.
 */

/** The ticks of the cooldown ring. */
const TICKS = 48;
/** The equipped-slot ring's geometry, in its 100-unit box. */
const INNER = 33;
const OUTER = 50;
/** The strip's small rings: a thinner band of the same ticks. */
const MINI_INNER = 41;

const DEFAULT_UPGRADED: AbilityId[] = ['round-bomb', 'square-bomb', 'lock'];

type Cooling = { start: number; ms: number };

/** The demo, one step per beat: a beat's length in ms, then what happens. */
type Step = [number, { select?: AbilityId; use?: boolean; upgrade?: AbilityId; reset?: boolean }];
const SCRIPT: Step[] = [
  [900, { use: true }],
  [1400, { use: true }],
  [3300, {}],
  [700, { select: 'square-bomb' }],
  [900, { use: true }],
  [1100, { select: 'magnet' }],
  [800, { use: true }],
  [1100, { select: 'lock' }],
  [800, { use: true }],
  [1200, { select: 'ice' }],
  [900, { upgrade: 'ice' }],
  [1100, { use: true }],
  [1300, { select: 'camera' }],
  [800, { use: true }],
  [1100, { select: 'bike' }],
  [800, { use: true }],
  [1300, { select: 'lock' }],
  [1700, { use: true }],
  [2600, { select: 'round-bomb' }],
  [1500, { reset: true }],
];

function tickLine(index: number, inner = INNER) {
  const angle = ((index + 0.5) / TICKS) * Math.PI * 2;
  const sin = Math.sin(angle);
  const cos = Math.cos(angle);
  return {
    x1: (50 + inner * sin).toFixed(2),
    y1: (50 - inner * cos).toFixed(2),
    x2: (50 + OUTER * sin).toFixed(2),
    y2: (50 - OUTER * cos).toFixed(2),
  };
}
const LINES = Array.from({ length: TICKS }, (_, index) => tickLine(index));
const MINI_LINES = Array.from({ length: TICKS }, (_, index) => tickLine(index, MINI_INNER));

export function AbilityIcons({
  abilities = ABILITY_IDS,
  selected: selectedProp,
  defaultSelected,
  onSelectedChange,
  upgraded = DEFAULT_UPGRADED,
  cooldowns,
  onUse,
  defaultCooling,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** Which abilities the strip lists, in order. */
  abilities?: AbilityId[];
  /** The equipped ability, controlled; pair it with onSelectedChange. */
  selected?: AbilityId;
  /** The ability equipped at first, uncontrolled. */
  defaultSelected?: AbilityId;
  /** Called when the reader (or the demo) equips another ability. */
  onSelectedChange?: (ability: AbilityId) => void;
  /** The abilities that wear the "+" of an upgrade. */
  upgraded?: AbilityId[];
  /** Cooldowns in seconds, overriding each ability's own (0 for none). */
  cooldowns?: Partial<Record<AbilityId, number>>;
  /** Called when the equipped ability is used (not when it is refused because it is cooling). */
  onUse?: (ability: AbilityId) => void;
  /** Abilities already cooling at first, each with the share of its cooldown that has passed (0 to
   * 1); the ring goes on counting from there. Turns the demo off. */
  defaultCooling?: Partial<Record<AbilityId, number>>;
  /** While on screen and untouched, the HUD plays a short round of play. */
  autoplay?: boolean;
  /** A 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, take, visible, reduced } = useAutoplay<HTMLDivElement>(6000);
  const list = abilities.length ? abilities : ABILITY_IDS;
  const [ownSelected, setOwnSelected] = useState<AbilityId>(defaultSelected ?? list[0]);
  const selected = selectedProp && list.includes(selectedProp) ? selectedProp : ownSelected;
  /** Upgrades the demo hands out, on top of the prop's, until it starts over. */
  const [demoUpgraded, setDemoUpgraded] = useState<AbilityId[]>([]);
  /** The cooldowns asked for by defaultCooling: each ability's share passed, kept between 0 and 1. */
  const openCooling = () =>
    (Object.entries(defaultCooling ?? {}) as [AbilityId, number][])
      .map(([id, share]) => ({
        id,
        share: Math.min(1, Math.max(0, share || 0)),
        ms: Math.max(0, cooldowns?.[id] ?? ABILITIES[id]?.cooldown ?? 0) * 1000,
      }))
      .filter(({ id, share, ms }) => ms > 0 && share < 1 && list.includes(id));
  const [cooling, setCooling] = useState<Partial<Record<AbilityId, Cooling>>>(() => {
    const now = performance.now();
    return Object.fromEntries(
      openCooling().map(({ id, share, ms }) => [id, { start: now - share * ms, ms }]),
    );
  });
  /** Lit ticks per cooling ability; an ability missing here has all of them lit. */
  const [lit, setLit] = useState<Partial<Record<AbilityId, number>>>(() =>
    Object.fromEntries(openCooling().map(({ id, share }) => [id, Math.floor(share * TICKS)])),
  );
  const [message, setMessage] = useState('');
  const [pressed, setPressed] = useState(false);
  /** Whether an ability has been equipped since the first paint: only then do the name and the
   * slot's glyph animate in, so a fresh instance (or a locked specimen) shows them at once. */
  const [swapped, setSwapped] = useState(false);

  const slot = useRef<HTMLDivElement>(null);
  const slotGlyph = useRef<HTMLSpanElement>(null);
  const flash = useRef<SVGGElement>(null);
  const items = useRef(new Map<AbilityId, HTMLButtonElement>());
  const plusSeen = useRef(new Set<AbilityId>());

  const isUpgraded = (id: AbilityId) => upgraded.includes(id) || demoUpgraded.includes(id);
  const cooldownOf = (id: AbilityId) => Math.max(0, cooldowns?.[id] ?? ABILITIES[id].cooldown);
  const label = (id: AbilityId) => ABILITIES[id].name + (isUpgraded(id) ? ' +' : '');

  const animate = (
    element: Element | null | undefined,
    frames: Keyframe[],
    options: KeyframeAnimationOptions,
  ) => {
    if (!element || reduced) return;
    element.animate(frames, options);
  };

  const select = (id: AbilityId) => {
    if (id === selected) return;
    setOwnSelected(id);
    setSwapped(true);
    onSelectedChange?.(id);
    setMessage(`${label(id)} equipped${cooling[id] ? ', cooling' : ''}.`);
  };

  const use = () => {
    const id = selected;
    const name = label(id);
    if (cooling[id]) {
      animate(
        slot.current,
        [
          { transform: 'translateX(0)' },
          { transform: 'translateX(-6%)' },
          { transform: 'translateX(5%)' },
          { transform: 'translateX(-3%)' },
          { transform: 'translateX(2%)' },
          { transform: 'translateX(0)' },
        ],
        { duration: 340, easing: 'ease-out' },
      );
      const left = Math.ceil((cooling[id]!.start + cooling[id]!.ms - performance.now()) / 1000);
      setMessage(`${name} is not ready: ${left} s left.`);
      return;
    }
    animate(
      slotGlyph.current,
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.84, 0.9)', offset: 0.22 },
        { transform: 'scale(1.16)', offset: 0.5 },
        { transform: 'scale(1)' },
      ],
      { duration: 380, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
    );
    const seconds = cooldownOf(id);
    if (seconds > 0) {
      setCooling((current) => ({
        ...current,
        [id]: { start: performance.now(), ms: seconds * 1000 },
      }));
      setLit((current) => ({ ...current, [id]: 0 }));
      setMessage(`${name} used. Ready in ${seconds} s.`);
    } else setMessage(`${name} used.`);
    onUse?.(id);
  };

  const ready = useEffectEvent((id: AbilityId) => {
    setMessage(`${label(id)} ready.`);
    const item = items.current.get(id)?.querySelector('svg');
    animate(
      item,
      [
        { transform: 'scale(1)', filter: 'brightness(1)' },
        { transform: 'scale(1.22)', filter: 'brightness(1.6)', offset: 0.35 },
        { transform: 'scale(1)', filter: 'brightness(1)' },
      ],
      { duration: 520, easing: 'ease-out' },
    );
    if (id !== selected) return;
    animate(
      flash.current,
      [
        { opacity: 0, transform: 'scale(1)' },
        { opacity: 1, transform: 'scale(1.07)', offset: 0.3 },
        { opacity: 0, transform: 'scale(1)' },
      ],
      { duration: 620, easing: 'ease-out' },
    );
    animate(
      slotGlyph.current,
      [
        { filter: 'brightness(1) drop-shadow(0 0 0 transparent)' },
        { filter: 'brightness(1.5) drop-shadow(0 0 8px currentColor)', offset: 0.3 },
        { filter: 'brightness(1) drop-shadow(0 0 0 transparent)' },
      ],
      { duration: 700, easing: 'ease-out' },
    );
  });

  // The clock: turns each cooldown's elapsed time into lit ticks, while one runs and the HUD shows.
  const coolingKey = Object.keys(cooling).join();
  useEffect(() => {
    if (!coolingKey || !visible) return;
    let frame = 0;
    const tick = () => {
      const now = performance.now();
      const done: AbilityId[] = [];
      const counts: Partial<Record<AbilityId, number>> = {};
      for (const [id, entry] of Object.entries(cooling) as [AbilityId, Cooling][]) {
        const count = Math.floor(((now - entry.start) / entry.ms) * TICKS);
        if (count >= TICKS) done.push(id);
        else counts[id] = count;
      }
      setLit((current) => {
        const changed =
          done.some((id) => id in current) ||
          (Object.keys(counts) as AbilityId[]).some((id) => current[id] !== counts[id]);
        return changed ? counts : current;
      });
      if (done.length) {
        setCooling((current) => {
          const next = { ...current };
          for (const id of done) delete next[id];
          return next;
        });
        for (const id of done) ready(id);
        return;
      }
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [coolingKey, visible, cooling]);

  // A "+" that appears after the first paint pops in.
  useEffect(() => {
    for (const id of list) {
      const has = isUpgraded(id);
      if (has && !plusSeen.current.has(id)) {
        plusSeen.current.add(id);
        if (!demoUpgraded.includes(id)) continue;
        const pluses = root.current?.querySelectorAll(
          `svg[data-ability='${id}'] [data-part='plus']`,
        );
        pluses?.forEach((plus) =>
          animate(
            plus,
            [
              { transform: 'scale(0) rotate(-90deg)', opacity: 0 },
              { transform: 'scale(1.3) rotate(0deg)', opacity: 1, offset: 0.6 },
              { transform: 'scale(1) rotate(0deg)', opacity: 1 },
            ],
            { duration: 460, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
          ),
        );
        if (demoUpgraded.includes(id)) setMessage(`${ABILITIES[id].name} upgraded.`);
      } else if (!has) plusSeen.current.delete(id);
    }
  });

  const walk = useEffectEvent((id: AbilityId) => select(id));

  // The demo: a round of play, one step per beat, from the top each time it starts.
  const step = useEffectEvent((action: Step[1]) => {
    if (action.select && list.includes(action.select)) {
      // The highlight walks there one glyph at a time, as a d-pad would move it.
      const from = list.indexOf(selected);
      const to = list.indexOf(action.select);
      const path = from < to ? list.slice(from + 1, to + 1) : list.slice(to, from).reverse();
      path.forEach((id, index) =>
        window.setTimeout(() => {
          if (demoTimers.current.length) walk(id);
        }, index * 130),
      );
    }
    if (action.use) {
      setPressed(true);
      window.setTimeout(() => setPressed(false), 150);
      use();
    }
    if (action.upgrade && list.includes(action.upgrade))
      setDemoUpgraded((current) => [...current, action.upgrade!]);
    if (action.reset) setDemoUpgraded([]);
  });
  const demoTimers = useRef<number[]>([]);
  const demo = autoplay && !defaultCooling && playing;
  useEffect(() => {
    if (!demo) return;
    let index = 0;
    let timer = 0;
    const next = () => {
      const [beat, action] = SCRIPT[index];
      timer = window.setTimeout(() => {
        step(action);
        index = (index + 1) % SCRIPT.length;
        next();
      }, beat);
      demoTimers.current = [timer];
    };
    next();
    return () => {
      window.clearTimeout(timer);
      demoTimers.current = [];
    };
  }, [demo]);

  const onStripKey = (event: ReactKeyboardEvent) => {
    const index = list.indexOf(selected);
    const keys: Record<string, number> = {
      ArrowRight: index + 1,
      ArrowDown: index + 1,
      ArrowLeft: index - 1,
      ArrowUp: index - 1,
      Home: 0,
      End: list.length - 1,
    };
    if (event.key === 'Enter') {
      event.preventDefault();
      use();
      return;
    }
    if (!(event.key in keys)) return;
    event.preventDefault();
    const id = list[(keys[event.key] + list.length) % list.length];
    select(id);
    items.current.get(id)?.focus();
  };

  const selectedLit = cooling[selected] ? (lit[selected] ?? 0) : TICKS;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-swapped={swapped ? '' : undefined}
      onPointerDown={take}
      onKeyDown={take}
      onWheel={take}
    >
      <div className={styles.hud}>
        <div className={styles.top}>
          <div
            className={styles.strip}
            role="radiogroup"
            aria-label="Abilities"
            data-part="strip"
            onKeyDown={onStripKey}
          >
            {list.map((id) => {
              const entry = cooling[id];
              const count = entry ? (lit[id] ?? 0) : TICKS;
              return (
                <button
                  key={id}
                  ref={(element) => {
                    if (element) items.current.set(id, element);
                    else items.current.delete(id);
                  }}
                  type="button"
                  role="radio"
                  aria-checked={id === selected}
                  aria-label={`${label(id)}${entry ? ', cooling' : ''}`}
                  tabIndex={id === selected ? 0 : -1}
                  className={styles.item}
                  data-part="item"
                  data-cooling={entry ? '' : undefined}
                  onClick={() => select(id)}
                >
                  <svg
                    className={styles.mini}
                    viewBox="0 0 100 100"
                    aria-hidden="true"
                    focusable="false"
                    data-part="mini-ring"
                  >
                    {entry &&
                      MINI_LINES.map((line, index) => (
                        <line key={index} {...line} data-lit={index < count ? '' : undefined} />
                      ))}
                  </svg>
                  <AbilityGlyph ability={id} upgraded={isUpgraded(id)} className={styles.glyph} />
                </button>
              );
            })}
          </div>
          <p className={styles.name} data-part="name" aria-hidden="true">
            <span key={selected} className={styles.nameText}>
              {label(selected)}
            </span>
          </p>
        </div>

        <div className={styles.corner}>
          <div
            ref={slot}
            className={styles.slot}
            data-part="slot"
            data-cooling={cooling[selected] ? '' : undefined}
            aria-hidden="true"
          >
            <svg className={styles.ring} viewBox="0 0 100 100" focusable="false" data-part="ring">
              {LINES.map((line, index) => (
                <line key={index} {...line} data-lit={index < selectedLit ? '' : undefined} />
              ))}
              <g ref={flash} className={styles.flash}>
                {LINES.map((line, index) => (
                  <line key={index} {...line} />
                ))}
              </g>
            </svg>
            <span ref={slotGlyph} className={styles.slotGlyph} data-part="slot-glyph">
              <span key={selected} className={styles.swap}>
                <AbilityGlyph
                  ability={selected}
                  upgraded={isUpgraded(selected)}
                  className={styles.glyph}
                />
              </span>
            </span>
          </div>
          <button
            type="button"
            className={styles.use}
            data-part="use"
            data-pressed={pressed ? '' : undefined}
            aria-disabled={cooling[selected] ? true : undefined}
            onClick={use}
          >
            <span className={styles.key} aria-hidden="true">
              L
            </span>
            Use <span className={styles.visuallyHidden}>{label(selected)}</span>
          </button>
        </div>
      </div>
      <p className={styles.visuallyHidden} aria-live="polite" role="status">
        {message}
      </p>
    </div>
  );
}
```

### ability-icons/glyphs.tsx

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

'use client';

import { useId, type ReactNode } from 'react';

/*
 * The ability glyphs: flat, bold silhouettes, one colour each, drawn on a 54-unit square (the
 * motorbike is wider and spills 5 units out each side, so every glyph sits in the same 64 × 60
 * box). Details are cut out of the shape, never painted over it, so whatever is behind shows
 * through them: each glyph is a filled group under a mask whose black shapes are the cuts.
 *
 * An upgraded glyph gets a cream "+" in its lower right corner, with a 2.5-unit moat cut around it
 * so the badge reads on top of any colour.
 */

export type AbilityId =
  'round-bomb' | 'square-bomb' | 'magnet' | 'lock' | 'ice' | 'camera' | 'bike';

export const ABILITY_IDS: AbilityId[] = [
  'round-bomb',
  'square-bomb',
  'magnet',
  'lock',
  'ice',
  'camera',
  'bike',
];

export type Ability = {
  name: string;
  /** Its colour, which the player learns before its shape. */
  color: string;
  /** Seconds before it can be used again; 0 for none. */
  cooldown: number;
};

export const ABILITIES: Record<AbilityId, Ability> = {
  'round-bomb': { name: 'Round bomb', color: '#00c2ff', cooldown: 4 },
  'square-bomb': { name: 'Square bomb', color: '#00c2ff', cooldown: 4 },
  magnet: { name: 'Magnet', color: '#fe4a67', cooldown: 0 },
  lock: { name: 'Time lock', color: '#ffe23f', cooldown: 8 },
  ice: { name: 'Ice block', color: '#b2e7fa', cooldown: 3 },
  camera: { name: 'Camera', color: '#a4be00', cooldown: 0 },
  bike: { name: 'Motorbike', color: '#ffa800', cooldown: 6 },
};

/** The "+" badge, 22 units square, its middle at (44, 46). */
const PLUS = 'M40 35h8v7h7v8h-7v7h-8v-7h-7v-8h7z';

type Art = { shape: ReactNode; cuts?: ReactNode };

const ART: Record<AbilityId, Art> = {
  'round-bomb': {
    shape: (
      <>
        <circle cx="23" cy="31.5" r="21.5" />
        <rect
          x="-6.5"
          y="-3.5"
          width="13"
          height="7"
          rx="1.5"
          transform="translate(37.6 16.9) rotate(45)"
        />
        <path
          d="M40.5 14 44 10.5"
          fill="none"
          stroke="currentColor"
          strokeWidth="3.5"
          strokeLinecap="round"
        />
        <circle cx="46" cy="8.5" r="6.5" />
      </>
    ),
    cuts: (
      <>
        <path d="M29.3 12.4 42.1 25.2" strokeWidth="2.2" />
        <circle cx="46" cy="8.5" r="2.7" />
      </>
    ),
  },
  'square-bomb': {
    shape: (
      <>
        <rect x="5.5" y="17" width="43" height="37" rx="1.5" />
        <rect x="19" y="10" width="16" height="5.5" rx="1.2" />
        <circle cx="27" cy="6.5" r="6" />
      </>
    ),
    cuts: <circle cx="27" cy="6.5" r="2.5" />,
  },
  magnet: {
    shape: (
      <g transform="rotate(45 27 27)">
        <path
          d="M12.5 1V27a14.5 14.5 0 0 0 29 0V1"
          fill="none"
          stroke="currentColor"
          strokeWidth="10.5"
        />
      </g>
    ),
    cuts: (
      <g transform="rotate(45 27 27)">
        <path d="M5 9h15M34 9h15" strokeWidth="2.4" />
      </g>
    ),
  },
  lock: {
    shape: (
      <>
        <rect x="5.5" y="24" width="43" height="29.5" rx="3" />
        <path
          d="M14.5 26v-9a12.5 12.5 0 0 1 25 0v9"
          fill="none"
          stroke="currentColor"
          strokeWidth="7.5"
        />
      </>
    ),
    cuts: (
      <>
        <circle cx="27" cy="36.5" r="4.4" stroke="none" />
        <path d="M25 38.5 23.6 46.5h6.8L29 38.5z" stroke="none" />
      </>
    ),
  },
  ice: {
    shape: (
      <g fill="none" stroke="currentColor" strokeLinecap="round">
        {[0, 60, 120, 180, 240, 300].map((angle) => (
          <g key={angle} transform={`rotate(${angle} 27 27)`}>
            <path d="M27 27V3.5" strokeWidth="5.5" />
            <path d="M27 15.5l-6 -6M27 15.5l6 -6" strokeWidth="4.5" />
          </g>
        ))}
        <circle cx="27" cy="27" r="6.5" fill="currentColor" stroke="none" />
      </g>
    ),
    cuts: (
      <>
        <circle cx="27" cy="27" r="2.6" stroke="none" />
      </>
    ),
  },
  camera: {
    shape: (
      <>
        <rect x="3" y="14" width="48" height="34" rx="2.5" />
        <path d="M18 15 22 7.5Q23 6 24.8 6h10.4q1.8 0 2.8 1.5L42 15z" />
        <rect x="6" y="9" width="9" height="3.5" rx="1.5" />
      </>
    ),
    cuts: <circle cx="30" cy="31" r="10.3" fill="none" strokeWidth="3.2" />,
  },
  bike: {
    shape: (
      <>
        <circle cx="5.5" cy="39.5" r="9" />
        <circle cx="47.5" cy="39.5" r="9" />
        <path d="M-2 21h12q4 0 6-3l3-5q1.5-2 4-1.5l6 1.5q3 1 4 4l2 4h15l5-4.5 2 1.5-3 8q-2 3-6 3.5l-8 1-6 6.5q-1 1.5-3 1.5h-9q-2.5 0-4-2l-4-6.5H6q-3 0-4-2.5z" />
        <path
          d="M36 13 42 10M44 22l3.5 17.5"
          fill="none"
          stroke="currentColor"
          strokeWidth="4"
          strokeLinecap="round"
        />
      </>
    ),
    cuts: (
      <>
        <circle cx="5.5" cy="39.5" r="3.2" stroke="none" />
        <circle cx="47.5" cy="39.5" r="3.2" stroke="none" />
        <circle cx="5.5" cy="39.5" r="10.4" fill="none" strokeWidth="2" />
        <circle cx="47.5" cy="39.5" r="10.4" fill="none" strokeWidth="2" />
      </>
    ),
  },
};

/** One ability's glyph: an inline SVG, sized by its className (64 × 60 units). */
export function AbilityGlyph({
  ability,
  upgraded = false,
  className,
  title,
}: {
  ability: AbilityId;
  /** Shows the cream "+" badge of an upgraded ability. */
  upgraded?: boolean;
  className?: string;
  /** A name for screen readers; without it the glyph is decorative. */
  title?: string;
}) {
  const id = useId();
  const art = ART[ability];
  return (
    <svg
      className={className}
      viewBox="-5 -3 64 60"
      focusable="false"
      role={title ? 'img' : undefined}
      aria-hidden={title ? undefined : true}
      aria-label={title}
      style={{ color: ABILITIES[ability].color }}
      data-ability={ability}
    >
      <mask id={id} maskUnits="userSpaceOnUse" x="-5" y="-3" width="64" height="60">
        <rect x="-5" y="-3" width="64" height="60" fill="#fff" />
        <g fill="#000" stroke="#000">
          {art.cuts}
        </g>
        {upgraded && (
          <path d={PLUS} fill="#000" stroke="#000" strokeWidth="5" strokeLinejoin="round" />
        )}
      </mask>
      <g mask={`url(#${id})`} fill="currentColor">
        {art.shape}
      </g>
      {upgraded && (
        <path
          data-part="plus"
          d={PLUS}
          fill="#fefbe3"
          stroke="#fefbe3"
          strokeWidth="1"
          strokeLinejoin="round"
        />
      )}
    </svg>
  );
}
```

### ability-icons/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, visible, reduced, playing: visible && !reduced && !taken };
}
```

### ability-icons/ability-icons.module.css

```css
/* Ability icons (ability-icons.tsx): the HUD on its own, centred on the lab's plain stage, white by
   day and near-black at night: the ability strip on top, the equipped ability's name under it, then
   the equipped slot with its cooldown ring and the Use button.

   Sizes are in units of the drawing, `--u` for the strip and `--s` for the slot: one unit is 1px
   when the stage is wide enough, and both shrink with the stage's width (a container query), so
   the strip always fits a 320px phone.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). As in a game, the HUD brings its own dark plates (the strip, the name's
   pill, the slot's disc, the Use pill), so the glyphs, the cream words and the ring's dark ticks
   read the same on a white stage and a black one; on the light stage the plates also cast a soft
   shadow so they sit on it.

   Crispness. Everything is vector: SVG glyphs and rings, CSS plates. No shape is blurred or scaled
   as a bitmap; the only soft things are glows and shadows, painted behind the sharp shapes.

   Memory. The stage is one layout boundary sized from outside. The rings' ticks change only an
   attribute, 48 times per cooldown; everything that moves does it by transform, opacity or
   filter. */

.root {
  /* The stage, and its edge. */
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  /* The HUD plates, their edge and their shadow on the stage. */
  --plate: #3b3933;
  --plate-edge: rgb(254 251 227 / 0.12);
  --shade: rgb(30 28 20 / 0.16);
  --plate-shadow: 0 1px 2px var(--shade), 0 6px 18px var(--shade);
  /* The HUD's words and the glyph frame of the equipped ability. */
  --cream: #fefbe3;
  /* The cooldown rings: lit ticks, and dark ones still to come. */
  --tick: #b1ecf8;
  --tick-off: #1d1c18;
  --flash: #fff;
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  contain: size layout;
  width: 100%;
  aspect-ratio: 16 / 10;
  min-height: 380px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    'Hiragino Maru Gothic ProN',
    system-ui,
    -apple-system,
    'Segoe UI',
    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));
    --plate: light-dark(#3b3933, #2e2d28);
    --plate-edge: light-dark(rgb(254 251 227 / 0.12), rgb(254 251 227 / 0.16));
    --shade: light-dark(rgb(30 28 20 / 0.16), rgb(0 0 0 / 0));
    --focus: light-dark(#387bff, #6ea0ff);
  }
}
.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;
}

.hud {
  --u: min(1px, calc((100cqi - 24px) / 580));
  --s: min(1px, calc(100cqi / 520));
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: calc(26 * var(--s));
  padding: 16px 12px;
}

.top {
  display: grid;
  justify-items: center;
  gap: calc(8 * var(--u));
}

/* The strip: one 80-unit cell per ability. */
.strip {
  display: flex;
  padding: calc(4 * var(--u)) calc(6 * var(--u));
  border-radius: calc(18 * var(--u));
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    var(--plate-shadow);
}

.item {
  position: relative;
  display: grid;
  place-items: center;
  width: calc(80 * var(--u));
  height: calc(80 * var(--u));
  padding: 0;
  border: 0;
  border-radius: calc(14 * var(--u));
  background: none;
  color: inherit;
  cursor: pointer;
  touch-action: manipulation;
}
/* The frame of the equipped ability. */
.item::before {
  content: '';
  position: absolute;
  inset: calc(3 * var(--u));
  border: calc(2 * var(--u)) solid rgb(254 251 227 / 0.85);
  border-radius: calc(14 * var(--u));
  background: rgb(254 251 227 / 0.1);
  opacity: 0;
  transform: scale(0.8);
  transition:
    opacity 140ms ease-out,
    transform 220ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
.item[aria-checked='true']::before {
  opacity: 1;
  transform: scale(1);
}
.item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.glyph {
  display: block;
  overflow: visible;
  filter: drop-shadow(0 0 3px color-mix(in srgb, currentColor 45%, transparent));
}
.item .glyph {
  position: relative;
  width: calc(64 * var(--u));
  height: calc(60 * var(--u));
  transition:
    transform 220ms cubic-bezier(0.3, 1.5, 0.5, 1),
    opacity 200ms ease-out,
    filter 200ms ease-out;
}
.item[aria-checked='true'] .glyph {
  transform: scale(1.06);
  filter: drop-shadow(0 0 6px color-mix(in srgb, currentColor 70%, transparent));
}
.item[data-cooling] .glyph {
  transform: scale(0.78);
  opacity: 0.5;
  filter: saturate(0.6);
}
.item:hover:not([aria-checked='true']) .glyph {
  transform: scale(1.04);
}
.glyph [data-part='plus'] {
  transform-box: fill-box;
  transform-origin: center;
}

/* A cooling ability's small ring in the strip: the same 48 ticks as the slot's, drawn in SVG. */
.mini {
  position: absolute;
  inset: calc(4 * var(--u));
  width: calc(72 * var(--u));
  height: calc(72 * var(--u));
  overflow: visible;
  opacity: 0;
  transition: opacity 200ms ease-out;
}
.mini line {
  stroke: var(--tick-off);
  stroke-width: 4;
}
.mini line[data-lit] {
  stroke: var(--tick);
}
.item[data-cooling] .mini {
  opacity: 1;
}

.name {
  margin: 0;
  padding: calc(5 * var(--u)) calc(16 * var(--u));
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    var(--plate-shadow);
  color: var(--cream);
  font-size: max(13px, calc(17 * var(--u)));
  font-weight: 650;
  line-height: 1.3;
  letter-spacing: 0.01em;
}
.nameText {
  display: block;
}
.root[data-swapped] .nameText {
  animation: rise 220ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(35%);
  }
}

/* The equipped slot, and its Use button under it. */
.corner {
  display: grid;
  justify-items: center;
  gap: calc(12 * var(--s));
}

.slot {
  position: relative;
  display: grid;
  place-items: center;
  width: calc(124 * var(--s));
  height: calc(124 * var(--s));
  border-radius: 50%;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    var(--plate-shadow);
}
.ring {
  position: absolute;
  inset: calc(12 * var(--s));
  width: calc(100 * var(--s));
  height: calc(100 * var(--s));
  overflow: visible;
}
.ring line {
  stroke: var(--tick-off);
  stroke-width: 3;
}
.ring line[data-lit] {
  stroke: var(--tick);
}
.ring .flash {
  opacity: 0;
  transform-box: view-box;
  transform-origin: 50% 50%;
}
.ring .flash line {
  stroke: var(--flash);
}
.slotGlyph {
  position: relative;
  display: block;
}
.slot .glyph {
  /* 42 of the glyph's 54 units, in its 64 × 60 box. */
  width: calc(49.78 * var(--s));
  height: calc(46.67 * var(--s));
  transition:
    opacity 240ms ease-out,
    filter 240ms ease-out;
}
.slot[data-cooling] .glyph {
  opacity: 0.5;
  filter: saturate(0.6);
}
.swap {
  display: block;
}
.root[data-swapped] .swap {
  animation: pop 340ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.6);
  }
}

.use {
  display: inline-flex;
  align-items: center;
  gap: calc(8 * var(--s));
  min-height: 32px;
  padding: calc(5 * var(--s)) calc(14 * var(--s)) calc(5 * var(--s)) calc(5 * var(--s));
  border: 0;
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    var(--plate-shadow);
  color: var(--cream);
  font: inherit;
  font-size: max(13px, calc(15 * var(--s)));
  font-weight: 650;
  cursor: pointer;
  touch-action: manipulation;
  transition:
    transform 120ms ease-out,
    background-color 120ms ease-out;
}
.use:active,
.use[data-pressed] {
  transform: scale(0.93);
  background: color-mix(in srgb, var(--plate), var(--cream) 22%);
}
.use[aria-disabled='true'] {
  color: rgb(254 251 227 / 0.6);
}
.use:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.key {
  display: grid;
  place-items: center;
  min-width: 24px;
  height: 22px;
  padding: 0 5px;
  border-radius: 6px 6px 6px 10px;
  background: var(--cream);
  color: #2a2922;
  font-size: 13px;
  font-weight: 800;
}

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

@media (prefers-reduced-motion: reduce) {
  .item::before,
  .item .glyph,
  .slot .glyph,
  .use {
    transition: none;
  }
  .root[data-swapped] .nameText,
  .root[data-swapped] .swap {
    animation: none;
  }
}
```