# Ally tracker

> A game HUD’s column of companions: glowing creatures with charges, turning red and refilling while they recharge.

The column a game keeps at the edge of the screen for the allies whose powers the player can call on. Each creature glows in its own colour with its charges at its top right; a call squashes it, bursts it with a ring and pops the count, and the last charge drops it into red, dark and empty, filling from the bottom as it recharges until it flashes back. Click an ally, or press 1 to 4.

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

Category: Games · HUD.

## Anatomy

1. **Plate**: The column on a dark rounded plate, the same on light and dark stages, so the glows always read; one 3.75 em row per ally, in em of the count’s 24 px type, smaller on a narrow stage.
2. **Ally**: One row, holding a button named with its charges or its recharge; click, Enter, Space or its number key calls it.
3. **Ready creature**: The creature in its own colour: a thick stroke of the silhouette under a pale body, sharp at any pixel ratio, over a blurred copy that makes its halo.
4. **Charges**: “×N” in italic at the creature’s top right, white in its colour’s glow; it pops on every change.
5. **Spent creature**: At 0 charges: a red rim, a dark red body, no count, and a red halo that breathes while it recharges.
6. **Recharge fill**: A pale red copy of the creature, clipped from the top so it rises over the silhouette (not the box) as `recharge` goes from 0 to 1.
7. **Hint**: How to call an ally, on a neutral chip under the column with the keys as keycaps; left out with `bare`.

## When to use

- A game HUD with a few companions or summons whose powers the player can call on a limited number of times, then must wait for.
- Abilities with charges and a cooldown, where the player has to read at a glance which are ready, how many uses are left and how far a spent one has come back.
- A column at the screen’s edge that stays out of the way of play: a dark plate and no labels until hover or focus, a number key per ally.
- Calls the game makes as well as the player: given `allies`, it animates whatever the game does to the charges.

## When not to use

- A single ability on a cooldown. Use a round cooldown timer or a radial fill on its button.
- An ability bar of eight or more actions bound to keys. Use a hotbar with key hints along the bottom.
- Showing an ally’s health or status. Use a portrait with a health bar; this shows only charges and recharge.
- Buffs and effects that run out on their own. Use an effect timer with a countdown ring.
- Interfaces outside a game: a quota of credits or seats left. Use a meter with a count.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/ally-tracker.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 { AllyTracker, type AllyTrackerItem } from './ally-tracker';

export function Companions({
  allies,
  onCall,
}: {
  allies: AllyTrackerItem[];
  onCall: (id: string) => void;
}) {
  return <AllyTracker bare allies={allies} onUse={onCall} />;
}
```

## States

- **All ready**: Every ally full, each glowing in its colour with its count. The charge states are drawn `bare`: the column on its plate.
- **Charges used**: The stag at ×1 of 3 and the fox at ×1 of 2: still ready, still in colour.
- **Just spent**: `charges: 0`, `recharge: 0`: the ray in red with no count and no fill yet.
- **Recharging**: The ray 60% back and the fox 25%: the pale red rises from the bottom of each.
- **All spent**: Every ally red, each at its own point of recharge; calls on them are refused with a shake.
- **On its stage**: Without `bare`: the column centred on the plain stage, with the hint chip under it, at the page’s colours.
- **Narrow stage**: At 360 px the container query draws the column at 18 px type instead of 24.
- **Light**: `colorScheme="light"`: a white stage. The plate keeps the HUD dark, as a game’s does.
- **Dark**: `colorScheme="dark"`, or a dark page: the same HUD on a near-black stage, its plate set off by a faint edge.

## How it works

- Each creature is an original SVG on a 48-unit grid, drawn in stacked layers of the same shapes: a thick stroke in the ally’s colour (a crisp rim with no seams where shapes overlap), the pale body with its engraving in the colour, and, while spent, a copy in the recharge colours. The halo is one more copy of the silhouette behind them, the only layer with a filter (two drop-shadows), so the creature itself stays sharp at any pixel ratio.
- A spent ally swaps its tokens to red: a dark body with bright red marks under a red rim. The recharge copy is cut by clip-path: inset() from the top down to the fill line, worked out on the silhouette’s own top and height (--top, --span) so the pale red rises over the creature and not over empty box. It eases between the clock’s steps with a 140 ms linear transition.
- The moments are keyframes on the creature, which is remounted for each one so it plays again: a call squashes to 0.86 (anticipation), bursts to 1.16 with a brighter, wider glow and a ring, and settles; the last charge flashes, then shakes and flickers into the red; a call on a spent ally is refused with a shake; recharged, it flashes white in its colour from 0.72 with a ring, and the full count pops in with an overshoot.
- The glow and the flash are registered custom properties (@property --ally-glow and --ally-flash) animated on the halo layer, its blur and its mix toward white, so a spent ally’s halo can breathe slowly on its own while it works.
- Moments are read from changes in charges, so a controlled tracker animates what the game does: fewer charges is a call (or a spend at 0), a return from 0 is a recharge.
- Uncontrolled, it keeps its own allies and recharges them on a clock advanced every 100 ms only while it is on screen in a visible tab and not paused. A demo plays a short run of calls while it is untouched; any input hands it to the reader for 8 s.
- The column sits centred on a plain stage, on its own dark plate (a gradient with a hairline edge and a soft lift), as a game keeps a shade behind its glows: it reads the same on the light and the dark stage, whose colours, with the hint chip’s, are light-dark() tokens. Sizes are in em of the count’s 24 px type (a 60 px creature on a 90 px row), so a container query draws the column smaller on narrow screens, and a bare column takes its size from --ally-size.

## API reference

- **allies** (`AllyTrackerItem[]`): The allies to show, controlled: each is { id, kind, label, charges, maxCharges, recharge? }, with kind one of drake, ray, fox or stag and recharge from 0 to 1 while charges is 0. The game owns the values; presses call onUse. Turns the demo off.
- **defaultAllies** (`AllyTrackerItem[]`, default `drake ×1, ray ×1, fox ×2, stag ×3`): Uncontrolled, the allies it starts with.
- **onUse** (`(id: string) => void`): Called with an ally’s id when the player calls on it while it has a charge.
- **onRecharged** (`(id: string) => void`): Uncontrolled, called with an ally’s id when it has recharged.
- **rechargeSeconds** (`number`, default `12`): Uncontrolled, how long a spent ally takes to recharge, in seconds.
- **paused** (`boolean`, default `false`): Stops recharging, as a game’s pause menu does.
- **bare** (`boolean`, default `false`): The column on its plate alone, without the stage and the hint chip, to lay over a game of your own. Its type is 20 px, or --ally-size.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short run of play: calls, spent allies, recharges. Any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark stage, or the reader’s; without it, the page’s color-scheme. The HUD keeps its colours on its plate on both.
- **className** (`string`): Classes for the root.

## Accessibility

- A list labelled Allies; each ally is a real button named with its state, such as “Gale ray, 1 of 1 charge” or “Gale ray, recharging, 40%” (in steps of 10, so it does not chatter), with aria-keyshortcuts for its number.
- Click, Enter or Space calls an ally; 1 to 4 call them while focus is inside. Focus shows a white ring on the dark plate and the ally’s name on a dark tag, which also shows on hover.
- A polite live region says what happened: called and how many are left, spent and recharging, refused while recharging, ready again.
- The creatures, the count and the name tag are decorative (aria-hidden); the button’s name carries their meaning, and spent is shown by colour and by the missing count and the fill, not by colour alone.
- Under reduced motion the states change at once: no bursts, rings, shakes, pops or breathing, no autoplay, and the fill steps without easing.

## Source

### ally-tracker/ally-tracker.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { BeastArt, type AllyKind } from './beasts';
import { useAutoplay } from './use-autoplay';
import styles from './ally-tracker.module.css';

/*
 * Ally tracker: the game HUD's column of companions, on its own dark plate so it reads on a light
 * stage as well as a dark one. Each ally lends a power the player can call
 * on a few times (its charges); once spent, it needs time to come back. At a glance:
 *
 * - Ready: the creature glows in its own colour (a thick stroke of the silhouette under a pale
 *   body, over a halo that is a blurred copy of the silhouette behind it, so the creature itself
 *   stays sharp), with "×N" charges at its top right.
 * - Spent: the creature turns red, dark, with no count, and fills with a pale red from the bottom
 *   as it recharges (the fill is a copy of the art, clipped from the top to the silhouette's own
 *   height). Its red halo breathes slowly while it works.
 *
 * The moments of play animate on the creature, keyed so that each plays again: a call squashes it
 * (anticipation), bursts it with a ring and settles, and the count pops; the last charge does the
 * same, then shakes it into the red; a call on a spent ally is refused with a shake; when the fill
 * reaches the top, the creature flashes back into its colour with a ring and the full count pops in.
 *
 * Uncontrolled, it keeps its own allies and recharges them on a clock that only runs while it is
 * on screen in a visible tab and not paused; a demo plays a short run of calls while it is untouched.
 * Given `allies`, it shows those (the game owns charges and recharge) and reports presses through
 * `onUse`. Each ally is a button (click, Enter or Space), and 1–4 call them while focus is inside.
 */

export type { AllyKind };

export type AllyTrackerItem = {
  /** Stable id: the same id is the same ally. */
  id: string;
  /** Its creature and colour. */
  kind: AllyKind;
  /** Its name, for the tag on hover or focus and for screen readers. */
  label: string;
  /** Charges left. 0 is spent: red, recharging. */
  charges: number;
  /** Charges when full. */
  maxCharges: number;
  /** While spent, how far it has recharged, from 0 to 1. */
  recharge?: number;
};

/** Each creature's colour, and the top and height of its silhouette in % of its 48-unit box, so
    the recharge fill rises over the creature and not over empty box. */
const KIND: Record<AllyKind, { tint: string; top: number; span: number }> = {
  drake: { tint: '#08e6fd', top: 12, span: 76 },
  ray: { tint: '#8fe04e', top: 1, span: 92 },
  fox: { tint: '#f7630a', top: 4, span: 84 },
  stag: { tint: '#efd52c', top: 0, span: 88 },
};

const DEFAULT_ALLIES: AllyTrackerItem[] = [
  { id: 'drake', kind: 'drake', label: 'Shell drake', charges: 1, maxCharges: 1 },
  { id: 'ray', kind: 'ray', label: 'Gale ray', charges: 1, maxCharges: 1 },
  { id: 'fox', kind: 'fox', label: 'Ember fox', charges: 2, maxCharges: 2 },
  { id: 'stag', kind: 'stag', label: 'Storm stag', charges: 3, maxCharges: 3 },
];

type Moment = 'use' | 'spent' | 'deny' | 'recharged';

/** The demo: calls at ms from its start, by ally id. Every ally ends full again before it loops. */
const DEMO: { at: number; id: string }[] = [
  { at: 900, id: 'stag' },
  { at: 1900, id: 'stag' },
  { at: 3000, id: 'fox' },
  { at: 4300, id: 'ray' },
  { at: 6000, id: 'ray' },
  { at: 7200, id: 'fox' },
  { at: 9000, id: 'drake' },
  { at: 10600, id: 'stag' },
];
const DEMO_LENGTH = 26000;
/** Recharge time while the demo plays, so a loop shows it whole. */
const DEMO_RECHARGE = 10;

export function AllyTracker({
  allies,
  defaultAllies = DEFAULT_ALLIES,
  onUse,
  onRecharged,
  rechargeSeconds = 12,
  paused = false,
  bare = false,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The allies to show, controlled: the game owns charges and recharge, presses call onUse. Turns the demo off. */
  allies?: AllyTrackerItem[];
  /** Uncontrolled, the allies it starts with. */
  defaultAllies?: AllyTrackerItem[];
  /** Called with an ally's id when the player calls on it while it has a charge. */
  onUse?: (id: string) => void;
  /** Uncontrolled, called with an ally's id when it has recharged. */
  onRecharged?: (id: string) => void;
  /** Uncontrolled, how long a spent ally takes to recharge, in seconds. */
  rechargeSeconds?: number;
  /** Stops recharging, as a game's pause menu does. */
  paused?: boolean;
  /** The column on its plate alone, without the stage and the hint, to lay over a game of your own. */
  bare?: boolean;
  /** While on screen and untouched, a short run of play: calls, a spent ally, recharges. */
  autoplay?: boolean;
  /** Light or dark stage, or the reader's ('system'); without it, the page's color-scheme. The HUD keeps its colours. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, take } = useAutoplay<HTMLDivElement>(8000);
  const controlled = allies !== undefined;
  const [own, setOwn] = useState(defaultAllies);
  const list = allies ?? own;
  const demo = autoplay && playing && !controlled;
  const [message, setMessage] = useState('');
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  /** The last moment of each ally, with a serial so the same moment plays again. */
  const [moments, setMoments] = useState<Record<string, { type: Moment; n: number }>>({});
  const serial = useRef(0);
  const mark = (id: string, type: Moment) =>
    setMoments((now) => ({ ...now, [id]: { type, n: ++serial.current } }));

  // Moments read from changes, so a controlled tracker animates what the game does as well.
  const previous = useRef(new Map<string, number>());
  const sync = useEffectEvent((items: AllyTrackerItem[]) => {
    for (const ally of items) {
      const before = previous.current.get(ally.id);
      previous.current.set(ally.id, ally.charges);
      if (before === undefined || before === ally.charges) continue;
      if (ally.charges < before) {
        mark(ally.id, ally.charges === 0 ? 'spent' : 'use');
        announce(
          ally.charges === 0
            ? `${ally.label} called, spent, recharging`
            : `${ally.label} called, ${ally.charges} left`,
        );
      } else if (before === 0) {
        mark(ally.id, 'recharged');
        if (!controlled) onRecharged?.(ally.id);
        announce(
          `${ally.label} ready, ${ally.charges} ${ally.charges === 1 ? 'charge' : 'charges'}`,
        );
      }
    }
  });
  useEffect(() => sync(list), [list]);

  /** Calling on an ally: a charge spent, or a refusal while it recharges. */
  const call = (id: string) => {
    const ally = list.find((item) => item.id === id);
    if (!ally) return;
    if (ally.charges <= 0) {
      mark(id, 'deny');
      announce(`${ally.label} is recharging`);
      return;
    }
    onUse?.(id);
    if (!controlled)
      setOwn((now) =>
        now.map((item) =>
          item.id === id ? { ...item, charges: item.charges - 1, recharge: 0 } : item,
        ),
      );
  };
  const demoCall = useEffectEvent((id: string) => call(id));

  // The recharge clock: uncontrolled, only while someone can see it and the game is not paused.
  const seconds = demo ? DEMO_RECHARGE : rechargeSeconds;
  const recharging = !controlled && own.some((ally) => ally.charges === 0);
  const tick = useEffectEvent((ms: number) =>
    setOwn((now) =>
      now.map((ally) => {
        if (ally.charges > 0) return ally;
        const recharge = (ally.recharge ?? 0) + ms / (seconds * 1000);
        return recharge < 1
          ? { ...ally, recharge }
          : { ...ally, charges: ally.maxCharges, recharge: 0 };
      }),
    ),
  );
  useEffect(() => {
    if (!recharging || !visible || paused) return;
    let last = performance.now();
    const interval = window.setInterval(() => {
      const now = performance.now();
      tick(now - last);
      last = now;
    }, 100);
    return () => window.clearInterval(interval);
  }, [recharging, visible, paused]);

  // The demo: a run of calls, again and again while it may play.
  useEffect(() => {
    if (!demo) return;
    const timers: number[] = [];
    const run = () => {
      timers.push(...DEMO.map(({ at, id }) => window.setTimeout(() => demoCall(id), at)));
    };
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    return () => {
      timers.forEach((timer) => window.clearTimeout(timer));
      window.clearInterval(loop);
    };
  }, [demo]);

  const onKeyDown = (event: KeyboardEvent) => {
    if (!controlled) take();
    const index = Number(event.key) - 1;
    if (event.altKey || event.ctrlKey || event.metaKey || !(index >= 0 && index < list.length))
      return;
    event.preventDefault();
    call(list[index].id);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-bare={bare || undefined}
      onPointerDown={controlled ? undefined : take}
      onKeyDown={onKeyDown}
    >
      <ul className={styles.list} aria-label="Allies" data-part="list">
        {list.map((ally, index) => {
          const spent = ally.charges <= 0;
          const kind = KIND[ally.kind];
          const moment = moments[ally.id];
          return (
            <li
              key={ally.id}
              className={styles.ally}
              data-part="ally"
              data-state={spent ? 'spent' : 'ready'}
              style={
                {
                  '--tint': kind.tint,
                  '--top': `${kind.top}%`,
                  '--span': `${kind.span}%`,
                  '--fill': Math.min(1, Math.max(0, ally.recharge ?? 0)),
                } as CSSProperties
              }
            >
              <button
                type="button"
                className={styles.button}
                aria-keyshortcuts={index < 9 ? String(index + 1) : undefined}
                aria-label={
                  spent
                    ? `${ally.label}, recharging, ${Math.floor((ally.recharge ?? 0) * 10) * 10}%`
                    : `${ally.label}, ${ally.charges} of ${ally.maxCharges} ${ally.maxCharges === 1 ? 'charge' : 'charges'}`
                }
                onClick={() => {
                  if (!controlled) take();
                  call(ally.id);
                }}
              >
                <span
                  key={moment ? moment.n : 0}
                  className={styles.figure}
                  data-part="figure"
                  data-moment={moment?.type}
                >
                  <BeastArt kind={ally.kind} layer="edge" className={styles.glow} part="glow" />
                  <BeastArt kind={ally.kind} layer="edge" className={styles.edge} />
                  <BeastArt kind={ally.kind} layer="body" className={styles.body} />
                  {spent && (
                    <BeastArt kind={ally.kind} layer="fill" className={styles.fill} part="fill" />
                  )}
                  <span className={styles.ring} />
                </span>
                {!spent && (
                  <span
                    key={`${ally.charges}-${moment?.n ?? 0}`}
                    className={styles.count}
                    data-part="count"
                    data-pop={moment && moment.type !== 'deny' ? '' : undefined}
                    aria-hidden="true"
                  >
                    <span className={styles.times}>×</span>
                    {ally.charges}
                  </span>
                )}
                <span className={styles.name} data-part="name" aria-hidden="true">
                  {ally.label}
                </span>
              </button>
            </li>
          );
        })}
      </ul>
      {!bare && (
        <p className={styles.hint} aria-hidden="true" data-part="hint">
          Click an ally, or press
          <span className={styles.keys}>
            <kbd className={styles.key}>1</kbd>–<kbd className={styles.key}>{list.length}</kbd>
          </span>
        </p>
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### ally-tracker/beasts.tsx

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

/*
 * The four allies' art: original creatures of myth, each drawn on a 48 × 48 grid as a few filled
 * shapes (the silhouette) and a few marks inside it (its engraving). One component draws a creature
 * in one of three layers, so the tracker can stack them:
 *
 * - 'edge': the silhouette alone, stroked thick in the ally's colour. Under the body, it leaves a
 *   crisp coloured rim all round, with no seams where the shapes overlap.
 * - 'body': the silhouette filled with --body, and the engraving in --mark.
 * - 'fill': the same as 'body', in the recharge colours; the tracker clips it from the top, so it
 *   rises from the bottom as the ally recharges.
 */

export type AllyKind = 'drake' | 'ray' | 'fox' | 'stag';

type Art = {
  /** The silhouette: filled shapes, which may overlap. */
  shapes: string[];
  /** The engraving: stroked lines inside the silhouette. */
  lines: string;
  /** Dots of the engraving: [x, y, r]. */
  dots: [number, number, number][];
  /** Rings of the engraving: [x, y, r]. */
  rings?: [number, number, number][];
  /** A transform for the whole creature (the ray glides on a diagonal). */
  transform?: string;
};

export const ART: Record<AllyKind, Art> = {
  // A shell drake: a stocky dragon-turtle walking left, horned, under a domed shell with a medallion.
  drake: {
    shapes: [
      'M9 27C9 14 17 6 27.5 6S46 14 46 27Z',
      'M7 25.5H47.5C47.5 29 46 30.5 43.5 30.5H11C8.5 30.5 7 29 7 25.5Z',
      'M12 26C10.5 21 8 18.5 5 18.5C2.5 18.5 0.8 20.5 0.8 23C0.8 25.8 2.6 27.6 5.2 27.6H8L11 31Z',
      'M3.4 19.5L2 11.5L7.4 18.8Z',
      'M7 19.2L9.4 12.4L10.8 21Z',
      'M11 29H21V39C21 40.6 20 42 18.4 42H10.4C8.8 42 8.6 40.4 9.8 39.2L11 38Z',
      'M33 29H43V39.2C43 40.8 42 42 40.4 42H32.4C30.8 42 30.6 40.4 31.8 39.2L33 38Z',
    ],
    lines:
      'M27.5 7V12.5M31.5 19.5L38.5 24.5M23.5 19.5L16.5 24.5M27.5 21.5V25.5M14 21C15.5 15 19 11 23 9.2M41 21C39.5 15 36 11 32 9.2M11 33.5H21M33 33.5H43M14 39V42M17.5 39V42M36 39V42M39.5 39V42',
    dots: [
      [27.5, 17, 1.7],
      [13, 28, 1],
      [19, 28, 1],
      [25, 28, 1],
      [31, 28, 1],
      [37, 28, 1],
      [43, 28, 1],
      [4.4, 22.6, 1.2],
    ],
    rings: [[27.5, 17, 4.5]],
  },
  // A gale ray: a broad winged ray with a crest and a barbed tail, gliding up to the right.
  ray: {
    shapes: [
      'M24 5C27.4 5 29.6 8 30.6 11.6C36.6 12.6 42.6 16.6 47.4 23.6C41.4 24 36 25.8 31.4 29.6C30.2 33.4 27.6 35.6 24 35.6C20.4 35.6 17.8 33.4 16.6 29.6C12 25.8 6.6 24 0.6 23.6C5.4 16.6 11.4 12.6 17.4 11.6C18.4 8 20.6 5 24 5Z',
      'M19.6 8.6L17.6 1.6L22.6 6Z',
      'M28.4 8.6L30.4 1.6L25.4 6Z',
      'M21.8 34L24 47L26.2 34Z',
      'M24 38.4L18.6 43.2L24 41.4L29.4 43.2Z',
    ],
    lines:
      'M17.4 15C13 16.4 8.8 18.8 5 22.4M30.6 15C35 16.4 39.2 18.8 43 22.4M17.6 19.4C14.2 20.4 11 21.8 8.4 23.4M30.4 19.4C33.8 20.4 37 21.8 39.6 23.4M24 14V31M20 26.5L24 29.5L28 26.5',
    dots: [
      [21.4, 10.6, 1.2],
      [26.6, 10.6, 1.2],
      [12, 20.6, 1],
      [36, 20.6, 1],
    ],
    transform: 'translate(24 25) rotate(40) scale(1.12) translate(-24 -25)',
  },
  // An ember fox: a fox leaping to the right, its three tails streaming behind it like flames.
  fox: {
    shapes: [
      'M12 30C11 22.5 18 18 26.5 18C31.5 18 34.5 20.5 35.5 23.5L31.5 33C26.5 35.5 16 36 12 30Z',
      'M30 21C30 15 33.5 11.6 38 11.6L39.6 5.2L42.4 11.2L45.2 6.6L45.6 13.6C47.6 15 48.4 17.6 46.8 19.6L41 22.8C37.6 24.8 32.4 25 30 21Z',
      'M29 26.5L36.4 29L41.6 33.6L39.2 36.2L33.2 32.8L27.4 31.6Z',
      'M25.4 30.4L29.8 37.8L26.8 39.4L21.8 33Z',
      'M15.4 29.2L10.6 37.8L13.6 39.4L19.6 32.2Z',
      'M19.4 31.6L18 40.6H21.4L24.4 33Z',
      'M14.6 26.6C8.4 25.4 3.6 20 5 11.4C7.4 16.6 11 19.8 16.4 21.6Z',
      'M13.6 29.4C6.6 30.6 0.6 27.6 0 19.8C3.6 23.6 8.2 25.2 13.8 25Z',
      'M16 23C11.6 17.8 12.4 10.4 17.6 5.4C16.4 11.6 17.6 16.4 20.8 20Z',
    ],
    lines:
      'M8.6 16.6C9.8 18.8 11.8 20.4 14.2 21.4M4.2 23.6C6.6 25.2 9.4 26 12.4 26M16.2 10.8C15.6 14 16.2 16.8 17.8 19M20 24.6C23.6 23.2 27.4 23.6 30.4 25.8M33.2 20.2L37.4 22.2M40.8 9.6L41.6 13M21.6 29C24 28.2 26.4 28.4 28.4 29.4',
    dots: [[40.6, 16, 1.2]],
    transform: 'translate(24 24) rotate(-10) scale(1.04) translate(-24 -24)',
  },
  // A storm stag: a stag standing to the left, lightning for antlers and a plume for a tail.
  stag: {
    shapes: [
      'M12.6 22C12.6 17.6 16 15.6 20.6 15.6H35.4C40.2 15.6 43 18.4 43 23C43 28 40.6 31 36.2 31H18.4C14.6 31 12.6 27.6 12.6 22Z',
      'M14 23.6L10.4 12.6C9.6 10.2 8 9 5.6 9.2L2.2 10C0.4 10.8 0.2 13.2 1.8 14.4L6.4 16.2L10.6 27.6Z',
      'M6.4 9.6L3.4 4.2H6.6L5 0L9.8 5.6H7.8L9.6 9.6Z',
      'M9.2 9.6L12 3.6L14 5.4L16.4 0.2L16.4 7.6L13.8 6.4L11.8 11Z',
      'M15 29L13.8 42.4H17.6L19.6 29Z',
      'M20.6 29.6L21 42.4H24.8L24.4 29.6Z',
      'M32.2 29.6L30.8 42.4H34.6L36.4 29.6Z',
      'M37.6 28.6L38.4 42.4H42.2L41.8 28Z',
      'M41.6 19C45.8 17.4 47.4 13.6 46.4 9.4C49 12.8 48.8 18.6 45.8 21.6C47.4 22.8 47.4 25.4 46.2 26.6C44.6 24.6 42.6 24 41.6 24Z',
    ],
    lines:
      'M18 26.2H39M11.6 15L13.6 21M44.2 20.6C45.6 18.4 46 16 45.6 13.6M13.9 40.2H17.7M21 40.2H24.8M30.9 40.2H34.7M38.3 40.2H42.1',
    dots: [
      [19.5, 21, 1.2],
      [24.5, 21, 1.2],
      [29.5, 21, 1.2],
      [34.5, 21, 1.2],
      [5, 12.2, 1.1],
    ],
  },
};

export function BeastArt({
  kind,
  layer,
  className,
  part,
}: {
  kind: AllyKind;
  layer: 'edge' | 'body' | 'fill';
  className?: string;
  /** A data-part name, so docs and tests can find the layer. */
  part?: string;
}) {
  const art = ART[kind];
  return (
    <svg
      className={className}
      data-part={part}
      viewBox="0 0 48 48"
      aria-hidden="true"
      focusable="false"
    >
      <g transform={art.transform}>
        {layer === 'edge' ? (
          <g fill="currentColor" stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round">
            {art.shapes.map((d) => (
              <path key={d} d={d} />
            ))}
          </g>
        ) : (
          <>
            <g fill="var(--body)">
              {art.shapes.map((d) => (
                <path key={d} d={d} />
              ))}
            </g>
            <path
              d={art.lines}
              fill="none"
              stroke="var(--mark)"
              strokeWidth="1.3"
              strokeLinecap="round"
            />
            {art.rings?.map(([x, y, r]) => (
              <circle
                key={`${x} ${y} ${r}`}
                cx={x}
                cy={y}
                r={r}
                fill="none"
                stroke="var(--mark)"
                strokeWidth="1.3"
              />
            ))}
            <g fill="var(--mark)">
              {art.dots.map(([x, y, r]) => (
                <circle key={`${x} ${y}`} cx={x} cy={y} r={r} />
              ))}
            </g>
          </>
        )}
      </g>
    </svg>
  );
}
```

### ally-tracker/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 };
}
```

### ally-tracker/ally-tracker.module.css

```css
/* Ally tracker (ally-tracker.tsx): the HUD's column of allies, alone and centred on a plain stage.
   Sizes are in em of the count's type (24 px at full size: a 60 px creature on a 90 px row), so
   the stage draws them smaller on a narrow screen (a container query), and a bare column takes
   its size from --ally-size.

   The column sits on its own dark plate, as a game HUD keeps a shade behind its glows, so the
   creatures read the same on a light stage and a dark one. Colours are the tokens below,
   light-dark(light, dark) where the two differ: the stage, the plate's edge and lift, and the
   hint chip; the HUD keeps its colours on both, as a game's does. Each ally sets --tint (its
   colour), --top and --span (its silhouette's height in its box) and --fill (its recharge).

   Crisp at any pixel ratio: the creatures are SVG with no filter on them; their halo is a copy
   of the silhouette behind them, and only that copy is blurred.

   Motion: the moments (call, spent, refused, recharged) are keyframes on the creature, which is
   remounted for each so they play again; the recharge fill is a clip-path that eases between the
   clock's steps; a spent ally's halo breathes through a registered custom property. */

@property --ally-glow {
  syntax: '<length>';
  inherits: true;
  initial-value: 0px;
}
@property --ally-flash {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

.root {
  /* The spent state: its red rim and halo, the dark body and the pale fill, with their marks. */
  --danger: #ff0a0a;
  --spent-body: #6c0000;
  --spent-mark: #d10000;
  --refill-body: #fcd8cc;
  --refill-mark: #ee4b42;
  /* Words on the plate, and the focus ring. */
  --ink: #f4f2ea;
  --ink-shadow: rgb(0 0 0 / 0.45);
  --focus: #fff;
  /* The plate under the column, and the name tag's banner: the same dark on both schemes. */
  --plate: #151a24;
  --plate-top: #1d2431;
  /* The plate's edge and lift on the stage. */
  --plate-edge: rgb(0 0 0 / 0.12);
  --plate-shadow: rgb(16 22 34 / 0.22);
  /* The stage, and the hint chip under the column. */
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  --chip: #f3f3f3;
  --chip-ink: #5d6068;
  --key: #fff;
  --key-edge: rgb(0 0 0 / 0.12);
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 1.1em;
  box-sizing: border-box;
  width: 100%;
  min-height: 440px;
  aspect-ratio: 16 / 9;
  padding: 24px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate-edge: light-dark(rgb(0 0 0 / 0.12), rgb(255 255 255 / 0.09));
    --plate-shadow: light-dark(rgb(16 22 34 / 0.22), rgb(0 0 0 / 0.5));
    --surface: light-dark(#fff, #121214);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --chip: light-dark(#f3f3f3, #232326);
    --chip-ink: light-dark(#5d6068, #a3a3aa);
    --key: light-dark(#fff, #313135);
    --key-edge: light-dark(rgb(0 0 0 / 0.12), rgb(255 255 255 / 0.1));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root[data-bare] {
  display: inline-block;
  width: auto;
  min-height: 0;
  aspect-ratio: auto;
  padding: 0;
  overflow: visible;
  border-radius: 0;
  background: none;
  box-shadow: none;
  container-type: normal;
}

/* The column on its plate: one row of 3.75 em per ally. */
.list {
  display: grid;
  grid-auto-rows: 3.75em;
  margin: 0;
  padding: 0.4em 0.8em 0.4em 0.75em;
  border-radius: 1.1em;
  background: linear-gradient(var(--plate-top), var(--plate));
  box-shadow:
    0 0 0 1px var(--plate-edge),
    0 0.5em 1.5em -0.3em var(--plate-shadow);
  list-style: none;
  font-size: var(--ally-size, 20px);
  line-height: 1;
}
.root:not([data-bare]) .list {
  font-size: var(--ally-size, clamp(18px, 2.5cqi, 24px));
}

.ally {
  --edge: var(--tint);
  --body: color-mix(in srgb, var(--tint) 8%, #fff);
  --mark: var(--tint);
  position: relative;
}
.ally[data-state='spent'] {
  --edge: var(--danger);
  --body: var(--spent-body);
  --mark: var(--spent-mark);
}

/* The button: the creature at its left, the count at its top right. */
.button {
  position: relative;
  display: block;
  width: 3.9em;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0.5em;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
}
.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

.figure {
  --ally-glow: 0.42em;
  position: absolute;
  top: 0.625em;
  left: 0;
  width: 2.5em;
  height: 2.5em;
}
.glow,
.edge,
.body,
.fill {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
/* The halo: a copy of the silhouette behind the creature, the only blurred layer. A flash mixes
   it toward white. */
.glow {
  color: color-mix(in srgb, var(--edge), #fff calc(var(--ally-flash) * 100%));
  filter: drop-shadow(0 0 calc(var(--ally-glow) * 0.3) var(--edge))
    drop-shadow(0 0 var(--ally-glow) color-mix(in srgb, var(--edge) 75%, transparent));
}
.edge {
  color: var(--edge);
}
/* The recharge: the pale copy, cut from the top down to the fill line of the silhouette. */
.fill {
  --body: var(--refill-body);
  --mark: var(--refill-mark);
  clip-path: inset(calc(var(--top) + (1 - var(--fill)) * var(--span)) -10% -10% -10%);
  transition: clip-path 140ms linear;
}

/* A ring that bursts out of the creature on a call or a recharge. */
.ring {
  position: absolute;
  inset: 0;
  border: 0.12em solid var(--edge);
  border-radius: 50%;
  opacity: 0;
}

/* The count: "×N", white in the ally's halo. */
.count {
  position: absolute;
  top: 0.2em;
  left: 2.19em;
  color: color-mix(in srgb, var(--tint) 10%, #fff);
  font-size: 1.12em;
  font-style: italic;
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-shadow:
    0 0 0.08em var(--tint),
    0 0 0.3em var(--tint);
  transform-origin: 20% 60%;
}
.times {
  margin-right: 0.03em;
  font-size: 0.74em;
}
.count[data-pop] {
  animation: pop 420ms cubic-bezier(0.3, 1.6, 0.5, 1);
}

/* The name, on hover or keyboard focus, on a banner to the creature's left. */
.name {
  position: absolute;
  top: 50%;
  right: calc(100% + 1em);
  z-index: 1;
  padding: 0.35em 0.85em;
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--plate-edge),
    0 0.3em 0.9em -0.2em var(--plate-shadow);
  color: var(--ink);
  font-size: 0.7em;
  font-style: italic;
  letter-spacing: 0.03em;
  white-space: nowrap;
  text-shadow: 0 0.04em 0.12em var(--ink-shadow);
  opacity: 0;
  translate: 0.6em -50%;
  pointer-events: none;
  transition:
    opacity 160ms ease-out,
    translate 220ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@media (hover: hover) {
  .button:hover .name {
    opacity: 1;
    translate: 0 -50%;
  }
}
.button:focus-visible .name {
  opacity: 1;
  translate: 0 -50%;
}

/* The moments. */
.figure[data-moment='use'],
.figure[data-moment='recharged'] {
  animation: call 520ms cubic-bezier(0.3, 0.9, 0.3, 1);
}
.figure[data-moment='recharged'] {
  animation-name: wake;
  animation-duration: 640ms;
}
.figure[data-moment='use'] .ring,
.figure[data-moment='recharged'] .ring,
.figure[data-moment='spent'] .ring {
  animation: ring 560ms 90ms cubic-bezier(0.1, 0.7, 0.3, 1);
}
.ally[data-state='spent'] .figure {
  animation: breathe 2.4s ease-in-out infinite;
}
.ally[data-state='spent'] .figure[data-moment='spent'] {
  animation:
    drop 760ms cubic-bezier(0.3, 0.9, 0.3, 1),
    breathe 2.4s 760ms ease-in-out infinite;
}
.ally[data-state='spent'] .figure[data-moment='deny'] {
  animation:
    deny 420ms ease-out,
    breathe 2.4s 420ms ease-in-out infinite;
}

@keyframes call {
  0% {
    scale: 1;
  }
  20% {
    scale: 0.86;
  }
  48% {
    scale: 1.16;
    --ally-flash: 0.4;
    --ally-glow: 0.7em;
  }
  100% {
    scale: 1;
  }
}
@keyframes wake {
  0% {
    scale: 0.72;
    --ally-flash: 0.85;
    --ally-glow: 0.9em;
  }
  50% {
    scale: 1.14;
    --ally-flash: 0.3;
  }
  100% {
    scale: 1;
  }
}
@keyframes drop {
  0% {
    scale: 0.86;
    --ally-flash: 0.7;
    --ally-glow: 0.8em;
  }
  30% {
    scale: 1.12;
    translate: 0 0;
  }
  42% {
    translate: -0.12em 0;
  }
  54% {
    translate: 0.12em 0;
    opacity: 0.55;
  }
  66% {
    translate: -0.08em 0;
    opacity: 1;
  }
  78% {
    translate: 0.05em 0;
  }
  100% {
    scale: 1;
    translate: 0 0;
  }
}
@keyframes deny {
  0%,
  100% {
    translate: 0 0;
  }
  15% {
    translate: -0.14em 0;
    --ally-glow: 0.75em;
  }
  35% {
    translate: 0.12em 0;
  }
  55% {
    translate: -0.08em 0;
  }
  75% {
    translate: 0.04em 0;
  }
}
@keyframes breathe {
  50% {
    --ally-glow: 0.5em;
  }
}
@keyframes ring {
  from {
    scale: 0.5;
    opacity: 0.9;
  }
  to {
    scale: 1.7;
    opacity: 0;
  }
}
@keyframes pop {
  from {
    scale: 0.4;
    opacity: 0;
  }
  60% {
    scale: 1.25;
    opacity: 1;
  }
}

/* The hint: a small neutral chip under the column, with the keys drawn as keycaps. */
.hint {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.35em;
  margin: 0;
  padding: 0.45em 0.85em;
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-ink);
  font-size: 13px;
  line-height: 1.2;
  text-align: center;
}
.key {
  display: inline-grid;
  place-items: center;
  min-width: 1.55em;
  height: 1.55em;
  box-sizing: border-box;
  padding: 0 0.3em;
  border-radius: 0.4em;
  background: var(--key);
  box-shadow:
    inset 0 0 0 1px var(--key-edge),
    0 1px 0 var(--key-edge);
  font-size: 0.92em;
  font-variant-numeric: tabular-nums;
}
.keys {
  display: inline-flex;
  align-items: center;
  gap: 0.2em;
}
@container (max-width: 419px) {
  .hint {
    font-size: 12px;
  }
}

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

@media (prefers-reduced-motion: reduce) {
  .fill,
  .name {
    transition: none;
  }
  .figure,
  .figure .ring,
  .count[data-pop],
  .ally[data-state='spent'] .figure,
  .ally[data-state='spent'] .figure[data-moment='spent'],
  .ally[data-state='spent'] .figure[data-moment='deny'] {
    animation: none;
  }
}
```