Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Stamina wheel

New

A game HUD stamina wheel: drains as the player sprints, warns red, and fades once full.

stamina-wheel/stamina-wheel.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 resource the player spends by holding or pressing actions (sprint, climb, glide, dash) and that refills on its own when they stop.
  • A HUD read out of the corner of the eye, next to the character, while the player watches the action and not a number.
  • Showing what an action is about to cost before it lands, or what it just cost, with a red wedge at the drain front.
  • A pool that grows over the game: a second and third wheel as thin rings, plus temporary bonus from food or potions.
  • A meter that should get out of the way: shown while it matters, faded once it is full and idle.

When not to use

  • Health or shields that take damage from outside and do not refill by themselves. Use a hearts row or a health bar.
  • A value the player needs to read exactly, such as 37 of 120. Use a bar with a count.
  • A cooldown on one ability, with no pool to spend. Use a radial cooldown on the ability icon.
  • Progress through a task or a download. Use a progress bar or ring outside the game HUD.
  • Anything that must stay on screen at all times, such as ammo. Use a fixed HUD counter; this one hides itself when full.

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

Into components/stamina-wheel of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/stamina-wheel.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

player-hud.tsx
import { StaminaGauge } from './stamina-wheel';export function PlayerHud({ stamina, bonus, tired }: { stamina: number; bonus: number; tired: boolean }) {  return <StaminaGauge value={stamina} wheels={2} bonus={bonus} exhausted={tired} />;}

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.

The green wheel that appears beside a player character the moment they sprint, climb or jump. A dark wedge eats it clockwise from 12 o’clock, extra wheels drain as thin outer rings first, red marks what an action costs, and small yellow bonus wheels from food are spent before the green. Run dry and it turns red and pulses until it has refilled; full again, it settles with a pop and fades away.

  • Every ring is one SVG circle stroked as wide as the ring (the main wheel from 10.6 to 30.4 units of its 90-unit frame, the second wheel’s ring from 33.7 to 38.3, the third’s from 40.5 to 45), and every wedge is a dash of that stroke with a pathLength of 1, so draining only changes two numbers and nothing is re-pathed.
  • The empty part grows clockwise from 12 o’clock. Stamina is counted in wheels: the outermost ring holds the top of the range and drains first, the main wheel last; refilling runs the other way.
  • The red wedge sits on the green around the drain front: ahead of it, what a held action will take (a dash being charged); behind it, a chunk just spent (a jump), which stays 220 ms and then shrinks into the front over 380 ms.
  • Bonus wheels are the main wheel at half size, 7.3 units clear of its outer edge (so they move out as rings are added), 36.6 to 37 units apart. The nearest is spent first; when it empties it pops out and the far one slides into its place.
  • Shown only when it matters: the first use pops it in with a slight overshoot; full and idle, it pops once with a green flash, waits 650 ms and fades out. Under a fifth of a wheel it pulses a red glow; exhausted, the whole wheel turns red and pulses while it slowly refills, and nothing can spend it.
  • The playground runs a small simulation on requestAnimationFrame, only while something is moving and the demo is on screen. Untouched, a 23.5-second scripted run plays a sprint, two jumps, a charged dash, two meals and a long sprint into exhaustion; any real press hands it over.
  • It is shown on its own, with no game scene behind it, so it brings its own footing as a HUD does: an opaque dark track and a 1-unit dark rim around every ring keep the green readable on a white page as on a black one. The green, red and yellow are the same on any scheme; the track, the rim and the buttons are light-dark() tokens. Every shape is a sharp SVG stroke; the only soft things are the glows, drawn as drop shadows behind it.
PropTypeDefaultDescription
valuenumber—StaminaGauge: stamina left, in wheels, from 0 to wheels. On StaminaWheel it turns the playground into a plain display of this value.
wheels1 | 2 | 31 (StaminaWheel: 2)How many wheels the player has: the main one, plus one or two rings around it.
bonusnumber0Temporary bonus stamina, in wheels (up to 2): small yellow wheels beside the main one.
bonusSide'left' | 'right''left'Where the bonus wheels sit: left beside a character, right in a menu.
costnumber0What a held action is about to take, in wheels: a red wedge reaching into what is left.
spentnumber0What was just taken, in wheels: a red wedge behind the drain front.
exhaustedbooleanfalseOut of stamina until full: the wheel turns red and pulses.
visibleboolean | 'auto''auto'StaminaGauge: shown or hidden; auto hides it 650 ms after it is full and idle, as a game does.
sizenumber—The main wheel’s size in px (its 90-unit frame). Without it, 104, then 128 on a playground 400 px wide or more and 160 from 640 px.
labelstring'Stamina'StaminaGauge: what a screen reader calls the meter.
autoplaybooleantrueStaminaWheel: while on screen and untouched, a scripted run plays the actions by itself.
colorScheme'light' | 'dark' | 'system'—Light or dark track and buttons, or the reader’s (system); without it, the page’s color-scheme.
classNamestring—Added to the root element.

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

stamina-wheel.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import {  BONUS,  EDGE,  RINGS,  VIEW,  bonusSlots,  bonusAt,  dash,  fill,  span,  wedgeBox,  type Ring,
geometry.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The wheel's drawing, as numbers: every ring is a circle stroked as wide as the ring, and every * wedge of it is a dash of that stroke. Units are the drawing's own: the main wheel's frame is * 90 units square, the wheel's middle is the origin, and angles run clockwise from 12 o'clock as * fractions of a turn. Kept apart from the component so the same numbers can be checked alone. *//** A ring: the circle through its middle and its width (inner and outer radius in the comments). */export type Ring = { r: number; width: number };/** The main wheel, 10.63 to 30.375, then the second wheel's ring, 33.66 to 38.25, then the * third's, 40.5 to 45. */export const RINGS: readonly Ring[] = [  { r: 20.503125, width: 19.74375 },  { r: 35.955, width: 4.59 },  { r: 42.75, width: 4.5 },];/** A bonus wheel is the main wheel at half its size, 5.32 to 15.19. */export const BONUS: Ring = { r: 10.2515625, width: 9.871875 };/** The outer edge of the wheel with one, two or three wheels. */const OUTER = [30.375, 38.25, 45];
stamina-wheel.module.css
/* Stamina wheel (stamina-wheel.tsx): the wheel (.gauge) and the playground around it (.root), the   wheel alone on a plain stage with a row of action buttons under it.   The wheel's green, red and yellow are its own and the same on any scheme. With no game scene   behind it, it brings its own footing, as a HUD does: an opaque dark track and a thin dark rim   around every ring, so it reads on a white page as on a black one. The track, the rim and the   buttons are light-dark(light, dark) tokens. The component takes the color-scheme it inherits   from the page, or the one its colorScheme prop sets (data-color-scheme).   Sizes: the wheel's frame is --size (104px; 128px on a stage 400px wide or more, 160px from   640px, or the size prop), and everything in the drawing scales with it. */.gauge {  --green: #13ff59;  --red: #ff6a64;  --yellow: #ffe460;  --track: #3a403c;  --edge: rgb(10 14 12 / 0.75);  --glow: rgb(19 255 89 / 0.7);  --warn: rgb(255 106 100 / 0.8);  position: relative;  display: inline-block;  width: var(--size, 90px);  height: var(--size, 90px);
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. `visible` and `reduced` are also returned, so * the component can pause its own clock offscreen and calm its effects. */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]) => {
  • The wheel is a meter (role="meter") with its value in wheels and a value text such as “64% of 2 wheels, plus 0.6 bonus, low”; the drawing itself is aria-hidden.
  • The actions are real buttons with their keys in aria-keyshortcuts. Sprint and Dash are held: with the pointer, or with Space or Enter down on the focused button; they report aria-pressed while held. The wheel count is a group of toggle buttons.
  • The stage around the wheel can take focus: then Shift (hold) sprints, Space jumps, F (hold) charges a dash and E eats.
  • Being exhausted, recovering and gaining bonus stamina are announced through a polite live region, only for the reader’s own play, never for the scripted run.
  • Reduced motion: no scripted run, no pops or overshoots, and the warnings become a steady red glow instead of a pulse; the stamina itself still drains and refills, since that is the information.
  • The demo pauses offscreen and in a hidden tab, and lets go of anything it was holding when the reader takes over.