Spur gauge
NewA riding HUD of spur charges that pop when spent, come back one by one in blue, and stack gold bonuses.
spur-gauge/spur-gauge.tsxThe 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:
npx shadcn@latest add https://brunodelorence.com/r/spur-gauge.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
max | number | 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 | 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 | 0 | Bonus charges at first, when the gauge keeps the count. |
maxBonus | number | 2 | The most bonus charges a boost can stack up to. |
regen | boolean | true | The gauge restores spent charges on its own, one by one. Turn it off when the game restores them through charges. |
regenMs | number | 2000 | How long one charge takes to come back, in ms. |
regenDelay | number | 900 | How long after the last spur restoring starts, in ms. |
autoHide | boolean | true | Fades the gauge out once every charge is back and nothing has happened for 2.5 s. |
shown | boolean | true | Whether the gauge is on screen at all (the player is riding); off, it fades out. |
size | number | 96 | One slot’s width in px; 96 is the kit’s own size. The gauge also shrinks to fit its box. |
hints | boolean | 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 | 'Spur charges' | The gauge’s accessible name. |
autoplay | boolean | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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// 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 (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 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.