# Spur gauge

> A riding HUD of spur charges that pop when spent, come back one by one in blue, and stack gold bonuses.

Each kick of the mount spends one charge. A spent rowel pops into a dim silhouette, so the maximum stays in view; after a short wait the next one fills up in blue and settles pale yellow with a flash. Gold rowels with a feather are bonus charges from a boost: spent first, never restored. With nothing left, a spur is refused with a shake; once every charge is back, the gauge fades away.

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

Category: Games · HUD.

## Anatomy

1. **Box**: With its chips, the component fills the width it is given and centres the gauge, the chips 28 px below it. `hints={false}` leaves just the gauge, sized by its slots.
2. **Gauge**: One row of slots on a dark pill, the same on both schemes so the pale glyphs read on a light page as on a game. A `role="meter"` whose value is the ready count out of `max`; it fades out when full and idle.
3. **Ready charge**: A 13-point rowel with its pin, in pale yellow with a soft glow: one spur the player can spend now.
4. **Restoring charge**: The first spent slot after `regenDelay`: a blue copy of the rowel uncovered from the bottom up over `regenMs`.
5. **Spent charge**: The same rowel as a 24% silhouette with no glow, so the maximum stays readable.
6. **Bonus charge**: A gold rowel with a feather, beyond `max`: spent before the others, never restored, its slot folds away once spent.
7. **Button chips**: Two real buttons under the gauge, Y to boost and A to spur; the same keys work while focus is anywhere in the component.

## When to use

- A small pool of charges the player spends in bursts and gets back over time: a mount’s spurs, dashes, dodges, sprint stocks.
- A count the player must read mid-action, in the corner of their eye: one glyph a charge, so four is four shapes, not a number.
- Showing what is spent as well as what is left, so the maximum stays in view and the next restore is visible as it fills.
- Temporary extras from a buff or a pickup, stacked beyond the maximum in gold and spent first.
- A HUD element that should get out of the way when it has nothing to say: it fades once every charge is back.

## When not to use

- A continuous resource such as health, stamina or fuel. Use a bar or a stamina wheel.
- A large count (ammo, coins, more than eight of anything). Use a number with an icon, such as a coin counter.
- A single ability with a cooldown. Use an ability icon with a cooldown ring.
- A timed buff or debuff that runs out. Use an effect timer.
- Progress or quotas in a work tool, where a game glyph reads as a gimmick. Use a progress bar or a plain count.

## Installation

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

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

export function RidingHud({ spurs, boosts }: { spurs: number; boosts: number }) {
  return (
    <SpurGauge
      hints={false}
      max={4}
      charges={spurs}
      bonus={boosts}
      regen={false}
      autoplay={false}
    />
  );
}
```

## States

- **Full**: Every charge ready. With `autoHide` the gauge fades 2.5 s later; here it is held with `autoHide={false}`.
- **Spent**: `charges={2}` of four: two pale rowels, two silhouettes keeping the maximum in view.
- **Restoring**: The first spent slot half filled in blue, held with `restoring={0.5}`; live, it fills over `regenMs`.
- **Last charge**: One spur left and no bonus: its rowel breathes an amber glow, a warning.
- **Empty**: `charges={0}`: only silhouettes. A spur now is refused with a shake and a red flush.
- **Bonus**: `bonus={2}`: two gold rowels with feathers after the four of the mount, spent first.
- **Eight slots**: `max={8}`, the most: the row shrinks to fit its box instead of overflowing it.
- **Without the chips**: `hints={false}`: just the gauge on its pill, sized by its slots, to lay over a game.
- **Faded out**: `shown={false}`, or full and idle with `autoHide`: the chips stay and the gauge is gone until the next spur.
- **Light**: `colorScheme="light"`: light chips and a soft shadow under the pill. The HUD colours are the same on both.
- **Dark**: `colorScheme="dark"`: dark chips and a hairline edge that lifts the pill off a dark page, under the same pale glyphs.

## How it works

- A charge is one SVG glyph: a 13-point rowel with a pin (a hub and a shank) over it, and on a bonus a feather. A mask cuts a 2.7-unit gap around the pin and the feather, so the pill shows through between the parts. The glyph is a single currentColor, so each state is a colour and a glow on its slot.
- Animations follow the counts, not the input: a lower count pops the slots it lost, a higher one plays the restore on the slots it gained, worked out during render so the first frame of a change already plays it. The gauge looks the same whether it keeps its own count or a game passes charges and bonus.
- A pop is a squash to 0.9, a burst to 1.28 with a white flash and six shards flung outwards, then the silhouette at 24% opacity, in 460 ms; the row nudges forward 3 px. A bonus pops the same way, then its slot folds to nothing.
- Restoring waits regenDelay after the last spur, then a blue copy of the glyph is uncovered from the bottom up by a clip-path animation lasting regenMs. Its animationend restores the charge, so the fill and the count never drift apart. The charge flashes white with a ring and settles from 1.18 to 1. The next fill follows 120 ms later.
- A spur with nothing left shakes the row and flushes the silhouettes red. With one charge left, its rowel pulses an amber glow as a warning. Once full and idle for 2.5 s, the gauge fades out and drops 10 px; the next spur brings it back.
- The row sits on its own dark pill, as a game HUD’s does, so the pale glyphs and their glows read on a light page, a dark one or a game alike; the HUD colours are the same on both schemes, and only the pill’s edge and shadow and the button chips follow the scheme. Glows are drop-shadows behind sharp SVG shapes, never a blur on the shapes themselves.
- Sizes are in units of the 96 px slot, so the size prop scales glyphs, glows, bursts and the pill together. The gauge also shrinks to fit a narrow box.

## API reference

- **max** (`number`, default `4`): The mount’s own charges: how many slots the gauge draws (1 to 8).
- **charges** (`number`): Ready charges, for a game that keeps the count (with onChange). Without it, the gauge keeps its own.
- **defaultCharges** (`number`, default `max`): Ready charges at first, when the gauge keeps the count.
- **bonus** (`number`): Bonus charges (gold, beyond max), for a game that keeps the count.
- **defaultBonus** (`number`, default `0`): Bonus charges at first, when the gauge keeps the count.
- **maxBonus** (`number`, default `2`): The most bonus charges a boost can stack up to.
- **regen** (`boolean`, default `true`): The gauge restores spent charges on its own, one by one. Turn it off when the game restores them through charges.
- **regenMs** (`number`, default `2000`): How long one charge takes to come back, in ms.
- **regenDelay** (`number`, default `900`): How long after the last spur restoring starts, in ms.
- **autoHide** (`boolean`, default `true`): Fades the gauge out once every charge is back and nothing has happened for 2.5 s.
- **shown** (`boolean`, default `true`): Whether the gauge is on screen at all (the player is riding); off, it fades out.
- **size** (`number`, default `96`): One slot’s width in px; 96 is the kit’s own size. The gauge also shrinks to fit its box.
- **hints** (`boolean`, default `true`): Shows the A and Y button chips under the gauge. Off: just the gauge, to lay over a game (and no demo).
- **restoring** (`number`): Holds the first spent slot mid-restore, this far filled (0 to 1), instead of restoring it: a still for docs and screenshots. Turns the demo off.
- **onChange** (`(value: { charges: number; bonus: number }) => void`): Called with the new counts on every spur, boost and restore.
- **onSpur** (`(value: { charges: number; bonus: number }) => void`): Called on a spur that spent a charge, with what is left.
- **onRestore** (`(charges: number) => void`): Called when a charge comes back, with the ready count.
- **onDenied** (`() => void`): Called on a spur with nothing left.
- **label** (`string`, default `'Spur charges'`): The gauge’s accessible name.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, plays a short ride: a boost, spurs down to nothing, a refusal, the refill. Any pointer or key hands it over.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark button chips and pill edge, or the reader’s (system); without it, the page’s color-scheme.
- **className** (`string`): Classes added to the root.

## Accessibility

- The gauge is a meter: its value is the ready count out of max, and its value text adds the bonus and a charge being restored, such as “2 of 4 spurs ready, 1 bonus, one restoring”.
- A polite live region announces each spur, restore, boost and refusal: “No spurs left”, “Spur restored, 3 of 4”, “All spurs ready”.
- The button chips are real buttons. A spurs and Y boosts while focus is anywhere in the component, and the buttons say so with aria-keyshortcuts. Focus shows a ring in the page’s ink.
- The glyphs, the flashes and the shards are decorative and hidden from assistive technology.
- With reduced motion there are no pops, shards, shakes or pulses, and states cross-fade. The blue fill still rises, since it shows the time until the next charge. The demo does not play.

## Source

### spur-gauge/spur-gauge.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type AnimationEvent as ReactAnimationEvent,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { SpurGlyph } from './spur-glyph';
import { useAutoplay } from './use-autoplay';
import styles from './spur-gauge.module.css';

/*
 * Spur gauge: the riding HUD that counts the mount's bursts of speed. Each spur (a kick) spends
 * one charge; spent charges come back one by one.
 *
 * - A charge is a rowel glyph in a slot. Pale yellow: ready. Blue: being restored, filling from
 *   the bottom over `regenMs`; when full it flashes and settles pale yellow. A dim silhouette:
 *   spent, so the player still sees the maximum. Gold with a feather: a bonus charge beyond the
 *   maximum (from a buff), spent first, never restored, and its slot folds away once spent.
 * - Every change is read from the values, not from the input that caused it: a lower charge count
 *   pops the slots it lost (a squash, a burst with a flash and six shards, the silhouette left
 *   behind), a higher one plays the restore on the slots it gained. So the gauge animates the
 *   same whether it keeps its own count or a game drives `charges` and `bonus`.
 * - Restoring waits `regenDelay` after the last spur, then fills the first spent slot by a CSS
 *   animation; its animationend is what restores the charge, so the fill and the count never
 *   drift apart. A spur meanwhile starts the wait again.
 * - A spur with nothing left is refused: the row shakes and the silhouettes flush red. With one
 *   charge left, the last rowel breathes its glow, a warning.
 * - When every charge is back and nothing has happened for a moment, the gauge fades out; the next
 *   spur brings it back (autoHide).
 * - The row sits on its own dark pill, as a game HUD's does, so its pale glyphs and their glows
 *   read the same on a light page, a dark one or a game. Below it, two small button chips, A to
 *   spur and Y to boost (a bonus charge). While on screen and untouched, a short ride plays on its
 *   own; any real input hands it over.
 */

/** The demo's ride, in ms from its start: a boost, a run of spurs down to nothing, a refusal. */
const RIDE: { at: number; press: 'spur' | 'boost' }[] = [
  { at: 900, press: 'boost' },
  { at: 2000, press: 'spur' },
  { at: 2700, press: 'spur' },
  { at: 3200, press: 'spur' },
  { at: 7000, press: 'spur' },
  { at: 7450, press: 'spur' },
  { at: 7900, press: 'spur' },
  { at: 8700, press: 'spur' },
];
/** The ride starts over after this long, once every charge is back and the gauge has faded. */
const LOOP = 21500;
/** How long a pop, a restore, a bonus's arrival and its exit play, in ms. */
const FX_MS = { pop: 460, restore: 420, gain: 560, leave: 560 } as const;
/** How long the gauge stays after the last change, once full, before it fades out. */
const LINGER = 2500;

type Fx = { kind: keyof typeof FX_MS; key: number };
type Bonus = { id: number; fx: Fx };

export type SpurGaugeValue = { charges: number; bonus: number };

let nextKey = 1;

export function SpurGauge({
  max = 4,
  charges: chargesProp,
  defaultCharges,
  bonus: bonusProp,
  defaultBonus = 0,
  maxBonus = 2,
  regen = true,
  regenMs = 2000,
  regenDelay = 900,
  autoHide = true,
  shown = true,
  size = 96,
  hints = true,
  restoring,
  onChange,
  onSpur,
  onRestore,
  onDenied,
  label = 'Spur charges',
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The mount's own charges: how many slots the gauge draws (1 to 8). */
  max?: number;
  /** Ready charges, for a game that keeps the count (with onChange); otherwise the gauge does. */
  charges?: number;
  /** Ready charges at first, when the gauge keeps the count. Defaults to max. */
  defaultCharges?: number;
  /** Bonus charges (gold, beyond max), for a game that keeps the count. */
  bonus?: number;
  /** Bonus charges at first, when the gauge keeps the count. */
  defaultBonus?: number;
  /** The most bonus charges a boost can stack up to. */
  maxBonus?: number;
  /** The gauge restores spent charges on its own, one by one. Off: the game does, through charges. */
  regen?: boolean;
  /** How long one charge takes to come back, in ms. */
  regenMs?: number;
  /** How long after the last spur restoring starts, in ms. */
  regenDelay?: number;
  /** Fades the gauge out once every charge is back and nothing has happened for a moment. */
  autoHide?: boolean;
  /** Whether the gauge is on screen at all (riding); off, it fades out. */
  shown?: boolean;
  /** One slot's width in px; 96 is the kit's own size. The gauge also shrinks to fit its box. */
  size?: number;
  /** Shows the A and Y button chips and plays the demo; off, just the gauge, to lay over a game. */
  hints?: boolean;
  /**
   * Holds the first spent slot mid-restore, this far filled (0 to 1), instead of restoring it: a
   * still, for docs and screenshots. Turns the demo off.
   */
  restoring?: number;
  /** Called with the new counts on every spur, boost and restore. */
  onChange?: (value: SpurGaugeValue) => void;
  /** Called on a spur that spent a charge, with what is left. */
  onSpur?: (value: SpurGaugeValue) => void;
  /** Called when a charge comes back, with the ready count. */
  onRestore?: (charges: number) => void;
  /** Called on a spur with nothing left. */
  onDenied?: () => void;
  /** The gauge's accessible name. */
  label?: string;
  /** While on screen and untouched, plays a short ride: a boost, spurs down to nothing, a refusal. */
  autoplay?: boolean;
  /** Light or dark chips and plate edge, or the reader's ('system'); without it, the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const slots = Math.max(1, Math.min(8, Math.round(max)));
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);

  // The counts: the game's when it passes them, the gauge's own otherwise.
  const [ownCharges, setOwnCharges] = useState(() => clamp(defaultCharges ?? slots, 0, slots));
  const [ownBonus, setOwnBonus] = useState(() => clamp(defaultBonus, 0, maxBonus));
  const charges = clamp(chargesProp ?? ownCharges, 0, slots);
  const bonus = Math.max(0, Math.round(bonusProp ?? ownBonus));

  // What each slot is playing, worked out from the change of the counts (during render, so the
  // first frame of a change already plays it).
  const [seen, setSeen] = useState({ charges, bonus });
  const [fx, setFx] = useState<Record<number, Fx>>({});
  const [bonuses, setBonuses] = useState<Bonus[]>(() =>
    Array.from({ length: bonus }, () => ({ id: nextKey++, fx: { kind: 'gain', key: 0 } })),
  );
  if (seen.charges !== charges || seen.bonus !== bonus) {
    setSeen({ charges, bonus });
    if (seen.charges !== charges) {
      const kind = charges < seen.charges ? 'pop' : 'restore';
      const next = { ...fx };
      const [from, to] = [Math.min(charges, seen.charges), Math.max(charges, seen.charges)];
      for (let index = from; index < to; index++) next[index] = { kind, key: nextKey++ };
      setFx(next);
    }
    if (seen.bonus !== bonus) {
      const staying = bonuses.filter((item) => item.fx.kind !== 'leave');
      let next = bonuses;
      if (bonus > staying.length) {
        const added = Array.from({ length: bonus - staying.length }, () => ({
          id: nextKey++,
          fx: { kind: 'gain', key: nextKey++ } as Fx,
        }));
        next = [...bonuses, ...added];
      } else {
        // The last ones still there leave, from the right.
        const leaving = new Set(staying.slice(bonus).map((item) => item.id));
        next = bonuses.map((item) =>
          leaving.has(item.id) ? { ...item, fx: { kind: 'leave', key: nextKey++ } } : item,
        );
      }
      setBonuses(next);
    }
  }

  // A bonus slot that has left is dropped once its exit has played.
  const dropBonus = (id: number) => setBonuses((now) => now.filter((item) => item.id !== id));

  const commit = (value: SpurGaugeValue) => {
    if (chargesProp === undefined) setOwnCharges(value.charges);
    if (bonusProp === undefined) setOwnBonus(value.bonus);
    onChange?.(value);
  };

  // Restoring: after the wait, the first spent slot fills; its animation's end restores it.
  const [filling, setFilling] = useState(0);
  const [lastSpur, setLastSpur] = useState(0);
  const quick = useRef(false);
  const still = restoring !== undefined;
  useEffect(() => {
    if (!regen || still || charges >= slots) return;
    const timer = window.setTimeout(() => setFilling(nextKey++), quick.current ? 120 : regenDelay);
    return () => {
      window.clearTimeout(timer);
      setFilling(0);
    };
  }, [regen, still, charges, slots, regenDelay, lastSpur]);

  const restored = (event: ReactAnimationEvent) => {
    if (event.target !== event.currentTarget) return;
    quick.current = true;
    setFilling(0);
    // Starts the next fill even if a game keeping the count has not taken this one yet.
    setLastSpur(nextKey++);
    const value = { charges: Math.min(slots, charges + 1), bonus };
    commit(value);
    onRestore?.(value.charges);
    announce(
      value.charges === slots ? 'All spurs ready' : `Spur restored, ${value.charges} of ${slots}`,
    );
    wake();
  };

  // Refusals and spurs shake or nudge the row; two names in turn restart the animation.
  const [shake, setShake] = useState<'' | 'a' | 'b'>('');
  const [nudge, setNudge] = useState<'' | 'a' | 'b'>('');
  const rowTimers = useRef({ shake: 0, nudge: 0 });
  const kick = (which: 'shake' | 'nudge') => {
    const set = which === 'shake' ? setShake : setNudge;
    set((now) => (now === 'a' ? 'b' : 'a'));
    window.clearTimeout(rowTimers.current[which]);
    rowTimers.current[which] = window.setTimeout(() => set(''), 420);
  };
  useEffect(() => {
    const timers = rowTimers.current;
    return () => {
      window.clearTimeout(timers.shake);
      window.clearTimeout(timers.nudge);
    };
  }, []);

  const [message, setMessage] = useState('');
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  // Showing: awake since the last change; asleep once full and still for LINGER ms.
  const [awake, setAwake] = useState(true);
  const [stirred, setStirred] = useState(0);
  const wake = () => {
    setAwake(true);
    setStirred(nextKey++);
  };
  const full = charges >= slots && !filling;
  useEffect(() => {
    if (!autoHide || !full) return;
    const timer = window.setTimeout(() => setAwake(false), LINGER);
    return () => window.clearTimeout(timer);
  }, [autoHide, full, stirred]);
  const visible = shown && (!autoHide || awake || !full);

  const spur = () => {
    wake();
    if (bonus > 0) {
      const value = { charges, bonus: bonus - 1 };
      commit(value);
      onSpur?.(value);
      kick('nudge');
      announce(`Bonus spur spent, ${value.bonus} bonus left, ${charges} of ${slots} ready`);
      return;
    }
    if (charges > 0) {
      const value = { charges: charges - 1, bonus };
      quick.current = false;
      setLastSpur(nextKey++);
      commit(value);
      onSpur?.(value);
      kick('nudge');
      announce(value.charges ? `${value.charges} of ${slots} spurs ready` : 'No spurs left');
      return;
    }
    kick('shake');
    onDenied?.();
    announce('No spurs left');
  };

  const boost = () => {
    wake();
    if (bonus >= maxBonus) {
      announce(`Bonus spurs full, ${bonus}`);
      return;
    }
    commit({ charges, bonus: bonus + 1 });
    announce(`Bonus spur gained, ${bonus + 1} bonus`);
  };

  // A button shows its press for a moment, from the demo as from the reader's keys.
  const [pressed, setPressed] = useState<'' | 'spur' | 'boost'>('');
  const pressTimer = useRef(0);
  const press = (which: 'spur' | 'boost') => {
    setPressed(which);
    window.clearTimeout(pressTimer.current);
    pressTimer.current = window.setTimeout(() => setPressed(''), 160);
    if (which === 'spur') spur();
    else boost();
  };
  useEffect(() => () => window.clearTimeout(pressTimer.current), []);

  const onKeyDown = (event: ReactKeyboardEvent) => {
    take();
    if (event.altKey || event.ctrlKey || event.metaKey || event.repeat) return;
    const key = event.key.toLowerCase();
    if (key !== 'a' && key !== 'y') return;
    event.preventDefault();
    press(key === 'a' ? 'spur' : 'boost');
  };

  // The demo: the ride, over and over, while it may play.
  const step = useEffectEvent((which: 'spur' | 'boost') => press(which));
  const demo = autoplay && !still && playing && hints;
  useEffect(() => {
    if (!demo) return;
    const timers: number[] = [];
    const ride = () => {
      for (const { at, press: which } of RIDE)
        timers.push(window.setTimeout(() => step(which), at));
      timers.push(window.setTimeout(ride, LOOP));
    };
    ride();
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);

  // The gauge shrinks to fit its box (never grows past `size`): the slot is drawn smaller, and
  // everything inside it is in units of the slot, the pill's padding too (12 units a side).
  const [fit, setFit] = useState(1);
  useEffect(() => {
    const element = root.current;
    if (!element || !hints) return;
    const measure = () => {
      const room = element.clientWidth - 32;
      setFit(Math.min(1, room / ((slots + maxBonus + 0.25) * size)));
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [root, hints, slots, maxBonus, size]);

  const last = charges === 1 && bonus === 0;
  const valueText =
    `${charges} of ${slots} spurs ready` +
    (bonus ? `, ${bonus} bonus` : '') +
    (filling || (still && charges < slots) ? ', one restoring' : '');

  const gauge = (
    <div
      className={styles.gauge}
      role="meter"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={slots}
      aria-valuenow={charges}
      aria-valuetext={valueText}
      data-part="gauge"
      data-shown={visible || undefined}
      data-shake={shake || undefined}
      data-nudge={nudge || undefined}
      style={{ '--slot': `${(size * fit).toFixed(2)}px` } as CSSProperties}
    >
      {Array.from({ length: slots }, (_, index) => {
        const state =
          index < charges ? 'ready' : index === charges && (filling || still) ? 'regen' : 'empty';
        const effect = fx[index];
        return (
          <span
            key={index}
            className={styles.slot}
            data-part="charge"
            data-state={state}
            data-fx={effect?.kind}
            data-last={(last && index === 0) || undefined}
          >
            <span key={`art-${effect?.key ?? 0}`} className={styles.art}>
              <SpurGlyph />
            </span>
            {state === 'regen' && (
              <span key={`fill-${filling}`} className={styles.fill} data-part="fill">
                <span
                  style={{
                    animationDuration: `${regenMs}ms`,
                    // A still: the fill held where `restoring` says, paused.
                    ...(still && {
                      animationDelay: `${-Math.max(0, Math.min(1, restoring)) * regenMs}ms`,
                      animationPlayState: 'paused',
                    }),
                  }}
                  onAnimationEnd={restored}
                >
                  <SpurGlyph />
                </span>
              </span>
            )}
            {effect && <Burst key={`burst-${effect.key}`} kind={effect.kind} />}
          </span>
        );
      })}
      {bonuses.map((item) => (
        <span
          key={item.id}
          className={styles.slot}
          data-part="bonus"
          data-state="bonus"
          data-fx={item.fx.key ? item.fx.kind : undefined}
          onAnimationEnd={(event) => {
            if (event.target === event.currentTarget && item.fx.kind === 'leave')
              dropBonus(item.id);
          }}
        >
          <span key={`art-${item.fx.key}`} className={styles.art}>
            <SpurGlyph feather />
          </span>
          {item.fx.key > 0 && <Burst key={`burst-${item.fx.key}`} kind={item.fx.kind} />}
        </span>
      ))}
    </div>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-hints={hints || undefined}
      data-color-scheme={colorScheme}
      role="group"
      aria-label="Riding HUD"
      onPointerDown={take}
      onKeyDown={onKeyDown}
    >
      {gauge}
      {hints && (
        <div className={styles.hints} data-part="hints">
          <button
            type="button"
            className={styles.hint}
            data-part="hint"
            data-pressed={pressed === 'boost' || undefined}
            aria-keyshortcuts="Y"
            onClick={() => press('boost')}
          >
            <span className={styles.face} aria-hidden="true">
              Y
            </span>
            Boost
          </button>
          <button
            type="button"
            className={styles.hint}
            data-part="hint"
            data-pressed={pressed === 'spur' || undefined}
            aria-keyshortcuts="A"
            onClick={() => press('spur')}
          >
            <span className={styles.face} aria-hidden="true">
              A
            </span>
            Spur
          </button>
        </div>
      )}
      <span className={styles.live} aria-live="polite">
        {message}
      </span>
    </div>
  );
}

/** The flash and the shards of a pop, or the ring of a restore. */
function Burst({ kind }: { kind: Fx['kind'] }) {
  if (kind === 'gain') return null;
  return (
    <span className={styles.burst} data-kind={kind} aria-hidden="true">
      <span className={styles.flash} />
      {kind !== 'restore' &&
        [0, 1, 2, 3, 4, 5].map((index) => (
          <span
            key={index}
            className={styles.shard}
            style={{ '--turn': `${index * 60 + 18}deg` } as CSSProperties}
          />
        ))}
    </span>
  );
}

function clamp(value: number, low: number, high: number) {
  return Math.max(low, Math.min(high, Math.round(value)));
}
```

### spur-gauge/spur-glyph.tsx

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

import { useId } from 'react';

/*
 * The spur glyph: a 13-point rowel with a pin (a round hub and a shank) laid over it, and, on a
 * bonus charge, a feather tucked into its lower right. Drawn in a 96-unit box centred on the
 * rowel's hub, the size of one slot of the gauge at its native scale.
 *
 * Where the pin and the feather sit on the rowel, a mask cuts a thin gap around them, so the
 * plate behind shows through: the three parts read apart without a darker outline colour. Everything
 * else is one fill (currentColor), so a state is just a colour and a glow on the slot.
 */

/** The rowel's points: tip and valley radii, the turn of its first tip from straight up. */
const TIP = 35.6;
const VALLEY = 21.5;
const POINTS = 13;
const FIRST = 3.3;
/** The point the shank covers is left out. */
const SKIP = 11;
/** The pin: the hub's radius, the shank's width and its far end, and the gap around both. */
const HUB = 8.6;
const SHANK = 7;
const SHANK_END = { x: -38.2, y: -23 };
const GAP = 2.7;

function rowel() {
  const step = 360 / POINTS;
  const at = (degrees: number, radius: number) => {
    const turn = (degrees * Math.PI) / 180;
    return `${(Math.sin(turn) * radius).toFixed(2)} ${(-Math.cos(turn) * radius).toFixed(2)}`;
  };
  const points: string[] = [];
  for (let index = 0; index < POINTS; index++) {
    const tip = FIRST + index * step;
    if (index !== SKIP) points.push(at(tip, TIP));
    points.push(at(tip + step / 2, VALLEY));
  }
  return `M${points.join('L')}Z`;
}

const ROWEL = rowel();
/** The feather: a quill at the lower left, a rounded top and three notches down its right edge. */
const FEATHER =
  'M4 38C8 27 14.5 14 23 3.6C27 2 34 3 37.6 6.8C38.4 9.6 37.8 12.4 36.8 14.8L30.6 16.4L35.2 20.2C34.2 21.6 32.8 23.4 31.2 25L25.4 25.2L28.8 29C26.6 30.6 24.2 32.2 21.4 33.6L16.6 32.2L18 35.8C13.4 37.4 8.6 38.4 4 38Z';

export function SpurGlyph({ feather = false }: { feather?: boolean }) {
  const id = useId();
  const mask = `spur${id.replace(/[^\w-]/g, '')}-gap`;
  return (
    <svg viewBox="-48 -48 96 96" aria-hidden="true" focusable="false">
      <defs>
        <mask id={mask} maskUnits="userSpaceOnUse" x="-48" y="-48" width="96" height="96">
          <rect x="-48" y="-48" width="96" height="96" fill="#fff" />
          <g fill="#000" stroke="#000" strokeLinecap="round" strokeLinejoin="round">
            <circle r={HUB + GAP} stroke="none" />
            <line x1="0" y1="0" x2={SHANK_END.x} y2={SHANK_END.y} strokeWidth={SHANK + GAP * 2} />
            {feather && <path d={FEATHER} strokeWidth={GAP * 2} />}
          </g>
        </mask>
      </defs>
      <g fill="currentColor" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
        <path d={ROWEL} strokeWidth="2" mask={`url(#${mask})`} />
        <circle r={HUB} stroke="none" />
        <line x1="0" y1="0" x2={SHANK_END.x} y2={SHANK_END.y} strokeWidth={SHANK} />
        {feather && <path d={FEATHER} stroke="none" />}
      </g>
    </svg>
  );
}
```

### spur-gauge/spur-gauge.module.css

```css
/* Spur gauge (spur-gauge.tsx): a row of rowel glyphs on a dark pill, with two button chips below.
   Sizes inside the gauge are in --u, one unit of the 96-unit slot (1px at the kit's own size), so
   a smaller slot scales the glyphs, their glows, their bursts and the pill together.

   Colours are the tokens below, light-dark(light, dark) where the two differ. The HUD's own
   colours (the glyphs, their glows, the pill) are the same on both schemes, as a game's are over
   any scene: the pill is what lets the pale glyphs read on a light page. Only the pill's edge and
   shadow and the chips follow the scheme. */

.root {
  /* The HUD, on both schemes. A ready charge, a restoring one, a bonus one, a spent one. */
  --ready: #fbfbbe;
  --regen: #a6edff;
  --gold: #fefc6b;
  --empty: #f4f1cf;
  --refused: rgb(255 72 60 / 0.95);
  /* The pill behind the row, its hairline edge and its shadow on the page. */
  --plate: rgb(26 31 33 / 0.92);
  --plate-edge: rgb(0 0 0 / 0.2);
  --plate-shadow: rgb(20 24 26 / 0.22);
  /* The chips: a quiet lab control, and the dark controller face inside it. */
  --chip: #fff;
  --chip-edge: rgb(0 0 0 / 0.1);
  --chip-ink: #2a2a28;
  --chip-press: #ececea;
  --face: #23292b;
  --face-ink: #f7f6f0;
  --focus: #1d1d1b;
  display: inline-block;
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    system-ui,
    -apple-system,
    'Segoe UI',
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate: light-dark(rgb(26 31 33 / 0.92), rgb(38 44 46 / 0.94));
    --plate-edge: light-dark(rgb(0 0 0 / 0.2), rgb(255 255 255 / 0.09));
    --plate-shadow: light-dark(rgb(20 24 26 / 0.22), rgb(0 0 0 / 0.45));
    --chip: light-dark(#fff, #262626);
    --chip-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --chip-ink: light-dark(#2a2a28, #e9e8e2);
    --chip-press: light-dark(#ececea, #333);
    --face: light-dark(#23292b, #f7f6f0);
    --face-ink: light-dark(#f7f6f0, #1d1d1b);
    --focus: light-dark(#1d1d1b, #fff);
  }
}
.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;
}
/* With its chips: the gauge centred in the width it is given, the chips below it. */
.root[data-hints] {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 28px;
  width: 100%;
  min-height: 300px;
  padding: 40px 16px;
  box-sizing: border-box;
}

.gauge {
  --u: calc(var(--slot) / 96);
  /* The glows: three shadows each, so one can animate into another. */
  --glow-ready: drop-shadow(0 0 calc(var(--u) * 1.5) rgb(252 250 196 / 0.9))
    drop-shadow(0 0 calc(var(--u) * 5.5) rgb(240 236 160 / 0.6)) drop-shadow(0 0 0 transparent);
  --glow-regen: drop-shadow(0 0 calc(var(--u) * 1.5) rgb(170 235 255 / 0.9))
    drop-shadow(0 0 calc(var(--u) * 6) rgb(60 145 225 / 1))
    drop-shadow(0 0 calc(var(--u) * 20) rgb(70 140 210 / 1));
  --glow-gold: drop-shadow(0 0 calc(var(--u) * 1.5) rgb(255 248 130 / 0.9))
    drop-shadow(0 0 calc(var(--u) * 8) rgb(225 195 70 / 0.8)) drop-shadow(0 0 0 transparent);
  --glow-off: drop-shadow(0 0 calc(var(--u) * 1) rgb(0 0 0 / 0.45)) drop-shadow(0 0 0 transparent)
    drop-shadow(0 0 0 transparent);
  display: flex;
  width: max-content;
  padding: calc(var(--u) * 2) calc(var(--u) * 12);
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    0 0 0 1px var(--plate-edge),
    0 calc(var(--u) * 4) calc(var(--u) * 16) var(--plate-shadow);
  opacity: 0;
  translate: 0 calc(var(--u) * 10);
  visibility: hidden;
  transition:
    opacity 240ms ease,
    translate 240ms cubic-bezier(0.2, 0.8, 0.2, 1),
    visibility 0s 240ms;
}
.gauge[data-shown] {
  opacity: 1;
  translate: 0 0;
  visibility: visible;
  transition:
    opacity 200ms ease,
    translate 280ms cubic-bezier(0.2, 1.3, 0.4, 1);
}
.gauge[data-nudge='a'] {
  animation: nudge-a 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gauge[data-nudge='b'] {
  animation: nudge-b 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gauge[data-shake='a'] {
  animation: shake-a 320ms linear;
}
.gauge[data-shake='b'] {
  animation: shake-b 320ms linear;
}

.slot {
  position: relative;
  flex: none;
  width: var(--slot);
  height: var(--slot);
}
.art,
.fill {
  position: absolute;
  inset: 0;
  transform-origin: 50% 50%;
}
.art svg,
.fill svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* The four looks of a charge. */
.art {
  color: var(--ready);
  filter: var(--glow-ready);
}
.slot[data-state='empty'] .art,
.slot[data-state='regen'] .art {
  color: var(--empty);
  opacity: 0.24;
  filter: var(--glow-off);
}
.slot[data-state='bonus'] .art {
  color: var(--gold);
  filter: var(--glow-gold);
}
/* The restoring charge: the same glyph in blue, uncovered from the bottom up. */
.fill {
  color: var(--regen);
  filter: var(--glow-regen);
}
.fill > span {
  position: absolute;
  inset: 0;
  animation: refill linear both;
}

/* A spent charge pops: a squash, a burst, and the silhouette left behind. */
.slot[data-fx='pop'] .art {
  animation: pop 460ms cubic-bezier(0.3, 0.7, 0.3, 1);
}
/* A bonus pops the same, then its slot folds away. */
.slot[data-fx='leave'] .art {
  animation: leave 460ms cubic-bezier(0.3, 0.7, 0.3, 1) both;
}
.slot[data-fx='leave'] {
  animation: fold 200ms 360ms ease-in both;
}
/* A restored charge turns from blue to pale with a pop of its own and settles. */
.slot[data-state='ready'][data-fx='restore'] .art {
  animation: restore 420ms cubic-bezier(0.3, 0.7, 0.3, 1);
}
/* A bonus drops in, turning, and settles. */
.slot[data-fx='gain'] .art {
  animation: gain 560ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
}
/* The last charge left breathes its glow. */
.slot[data-last] {
  animation: breathe 1100ms ease-in-out infinite;
}
/* A refusal flushes the silhouettes red. */
.gauge[data-shake] .slot[data-state='empty'],
.gauge[data-shake] .slot[data-state='regen'] {
  animation: refuse 320ms ease-out;
}

/* The bursts: a flash and six shards for a pop, a ring for a restore. */
.burst {
  position: absolute;
  inset: 0;
  pointer-events: none;
  color: var(--ready);
}
.slot[data-state='bonus'] .burst {
  color: var(--gold);
}
.flash {
  position: absolute;
  inset: 18%;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(255 255 255 / 0.95), rgb(255 255 230 / 0) 100%);
  opacity: 0;
  animation: flash 320ms ease-out;
}
.burst[data-kind='restore'] .flash {
  inset: 14%;
  background: none;
  box-shadow:
    0 0 0 calc(var(--u) * 2.5) var(--ready) inset,
    0 0 calc(var(--u) * 8) rgb(170 235 255 / 0.8);
  animation: ring 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.shard {
  position: absolute;
  left: calc(50% - var(--u) * 4);
  top: calc(50% - var(--u) * 6);
  width: calc(var(--u) * 8);
  height: calc(var(--u) * 12);
  background: currentColor;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  filter: drop-shadow(0 0 calc(var(--u) * 3) currentColor);
  opacity: 0;
  rotate: var(--turn);
  animation: shard 460ms cubic-bezier(0.15, 0.7, 0.3, 1);
}

@keyframes refill {
  from {
    clip-path: inset(88% 0 0 0);
  }
  to {
    clip-path: inset(11% 0 0 0);
  }
}
@keyframes pop {
  0% {
    color: var(--ready);
    opacity: 1;
    filter: var(--glow-ready);
    transform: scale(1);
  }
  22% {
    color: var(--ready);
    opacity: 1;
    filter: var(--glow-ready);
    transform: scale(0.9);
  }
  48% {
    color: #fff;
    opacity: 0.9;
    transform: scale(1.28);
  }
  100% {
    color: var(--empty);
    opacity: 0.24;
    filter: var(--glow-off);
    transform: scale(1);
  }
}
@keyframes leave {
  0% {
    transform: scale(1);
  }
  22% {
    transform: scale(0.9);
  }
  48% {
    color: #fff;
    opacity: 0.9;
    transform: scale(1.28);
  }
  100% {
    opacity: 0;
    transform: scale(0.6);
  }
}
@keyframes fold {
  to {
    width: 0;
  }
}
@keyframes restore {
  0% {
    color: var(--regen);
    filter: var(--glow-regen);
    transform: scale(1);
  }
  35% {
    color: #fff;
    transform: scale(1.18);
  }
  70% {
    transform: scale(0.96);
  }
  100% {
    transform: scale(1);
  }
}
@keyframes gain {
  0% {
    opacity: 0;
    transform: translateY(-34%) rotate(-120deg) scale(0.4);
  }
  60% {
    opacity: 1;
    transform: translateY(4%) rotate(8deg) scale(1.12);
  }
  80% {
    transform: translateY(0) rotate(-2deg) scale(0.97);
  }
  100% {
    transform: none;
  }
}
@keyframes breathe {
  0%,
  100% {
    filter: drop-shadow(0 0 0 rgb(255 200 90 / 0));
  }
  50% {
    filter: drop-shadow(0 0 calc(var(--u) * 7) rgb(255 196 80 / 0.85));
  }
}
@keyframes refuse {
  0% {
    filter: drop-shadow(0 0 calc(var(--u) * 6) var(--refused));
  }
  100% {
    filter: drop-shadow(0 0 0 transparent);
  }
}
@keyframes flash {
  0% {
    opacity: 1;
    transform: scale(0.3);
  }
  100% {
    opacity: 0;
    transform: scale(1.35);
  }
}
@keyframes ring {
  0% {
    opacity: 1;
    transform: scale(0.55);
  }
  100% {
    opacity: 0;
    transform: scale(1.45);
  }
}
@keyframes shard {
  0% {
    opacity: 1;
    transform: translateY(calc(var(--u) * -18)) scale(1);
  }
  100% {
    opacity: 0;
    transform: translateY(calc(var(--u) * -50)) scale(0.4);
  }
}
@keyframes nudge-a {
  40% {
    translate: calc(var(--u) * 3) 0;
  }
}
@keyframes nudge-b {
  40% {
    translate: calc(var(--u) * 3) 0;
  }
}
@keyframes shake-a {
  20% {
    translate: calc(var(--u) * -5) 0;
  }
  40% {
    translate: calc(var(--u) * 4) 0;
  }
  60% {
    translate: calc(var(--u) * -3) 0;
  }
  80% {
    translate: calc(var(--u) * 2) 0;
  }
}
@keyframes shake-b {
  20% {
    translate: calc(var(--u) * -5) 0;
  }
  40% {
    translate: calc(var(--u) * 4) 0;
  }
  60% {
    translate: calc(var(--u) * -3) 0;
  }
  80% {
    translate: calc(var(--u) * 2) 0;
  }
}

/* The button chips: a quiet pill each, a round controller face with its letter and the action. */
.hints {
  display: flex;
  gap: 10px;
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 34px;
  padding: 0 14px 0 5px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: 0 0 0 1px var(--chip-edge);
  color: var(--chip-ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 120ms ease;
}
.face {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--face);
  color: var(--face-ink);
  font-size: 13px;
  font-weight: 700;
  transition: scale 120ms ease;
}
.hint[data-pressed],
.hint:active {
  background: var(--chip-press);
}
.hint[data-pressed] .face,
.hint:active .face {
  scale: 0.86;
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

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

/* Reduced motion: no pops, shards, shakes or breathing; states cross-fade, and a refusal is only
   the red flush. The fill still rises, since it is what tells the time to the next charge. */
@media (prefers-reduced-motion: reduce) {
  .gauge,
  .gauge[data-shown] {
    translate: 0 0;
  }
  .gauge[data-nudge],
  .gauge[data-shake],
  .slot[data-last] {
    animation: none;
  }
  .slot[data-fx] .art {
    animation-name: fade;
    animation-timing-function: ease;
  }
  .burst {
    display: none;
  }
}
@keyframes fade {
  from {
    opacity: 0.5;
  }
}
```

### spur-gauge/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```