Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Spur gauge

New

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

spur-gauge/spur-gauge.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/spur-gauge of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/spur-gauge.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

riding-hud.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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 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.
PropTypeDefaultDescription
maxnumber4The mount’s own charges: how many slots the gauge draws (1 to 8).
chargesnumber—Ready charges, for a game that keeps the count (with onChange). Without it, the gauge keeps its own.
defaultChargesnumbermaxReady charges at first, when the gauge keeps the count.
bonusnumber—Bonus charges (gold, beyond max), for a game that keeps the count.
defaultBonusnumber0Bonus charges at first, when the gauge keeps the count.
maxBonusnumber2The most bonus charges a boost can stack up to.
regenbooleantrueThe gauge restores spent charges on its own, one by one. Turn it off when the game restores them through charges.
regenMsnumber2000How long one charge takes to come back, in ms.
regenDelaynumber900How long after the last spur restoring starts, in ms.
autoHidebooleantrueFades the gauge out once every charge is back and nothing has happened for 2.5 s.
shownbooleantrueWhether the gauge is on screen at all (the player is riding); off, it fades out.
sizenumber96One slot’s width in px; 96 is the kit’s own size. The gauge also shrinks to fit its box.
hintsbooleantrueShows the A and Y button chips under the gauge. Off: just the gauge, to lay over a game (and no demo).
restoringnumber—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.
labelstring'Spur charges'The gauge’s accessible name.
autoplaybooleantrueWhile 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.
classNamestring—Classes added to the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

spur-gauge.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
spur-glyph.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;
spur-gauge.module.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;
use-autoplay.ts
'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;
  • 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.