# Stamina wheel

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

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.

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

Category: Games · HUD.

## Anatomy

1. **Stage**: A plain, transparent stage 1.9 wheels tall, with the wheel in its middle; it takes focus for the keys.
2. **Meter box**: The `role="meter"` box, `--size` square (160 px on a stage 640 px wide). The drawing spills out to both sides for the bonus wheels.
3. **Main wheel**: A thick green ring, the last stamina to go: one circle stroked as wide as the ring.
4. **Outer ring**: The second and third wheels as thin rings around the first; the outermost drains first.
5. **Empty track**: The opaque dark track a wheel drains into, inside a thin dark rim so it holds on a light page: the empty part grows clockwise from 12 o’clock.
6. **Cost wedge**: A red wedge at the drain front: ahead of it what a held action will take (`cost`), behind it what was just spent (`spent`).
7. **Bonus wheel**: A yellow wheel at half size, one per bonus wheel (two at most), spent before the green.

## 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.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/stamina-wheel.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 { 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} />;
}
```

## States

- **Full**: `value` equal to `wheels`. On its own (`visible="auto"`) it would pop and fade 650 ms later.
- **Draining**: `value={1.4}` of 2: the outer ring has drained past half; the main wheel stays full until it is gone.
- **Charging**: `cost={0.25}`: a red wedge ahead of the front shows what the held dash will take.
- **Just spent**: `spent={0.15}`: a red wedge behind the front marks the jump that just landed.
- **Low**: Under a fifth of a wheel and no bonus: it pulses a quick red glow.
- **Exhausted**: `exhausted`: the whole wheel turns red and pulses slowly until it is full again.
- **Bonus**: `bonus={1.6}`: two yellow wheels to the left, the nearer one spent first.
- **Bonus right**: `bonusSide="right"`, as in a menu: the bonus wheels sit to the right, a touch higher.
- **Three wheels**: `wheels={3}`: two thin rings around the main wheel; the bonus wheels move out to clear them.
- **Light**: `colorScheme="light"`: a lighter track and a soft rim. The green, red and yellow never change.
- **Dark**: `colorScheme="dark"`: a track a shade lifted off the dark page, and a black rim.

## How it works

- 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.

## API reference

- **value** (`number`): StaminaGauge: stamina left, in wheels, from 0 to wheels. On StaminaWheel it turns the playground into a plain display of this value.
- **wheels** (`1 | 2 | 3`, default `1 (StaminaWheel: 2)`): How many wheels the player has: the main one, plus one or two rings around it.
- **bonus** (`number`, default `0`): Temporary bonus stamina, in wheels (up to 2): small yellow wheels beside the main one.
- **bonusSide** (`'left' | 'right'`, default `'left'`): Where the bonus wheels sit: left beside a character, right in a menu.
- **cost** (`number`, default `0`): What a held action is about to take, in wheels: a red wedge reaching into what is left.
- **spent** (`number`, default `0`): What was just taken, in wheels: a red wedge behind the drain front.
- **exhausted** (`boolean`, default `false`): Out of stamina until full: the wheel turns red and pulses.
- **visible** (`boolean | 'auto'`, default `'auto'`): StaminaGauge: shown or hidden; auto hides it 650 ms after it is full and idle, as a game does.
- **size** (`number`): 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.
- **label** (`string`, default `'Stamina'`): StaminaGauge: what a screen reader calls the meter.
- **autoplay** (`boolean`, default `true`): StaminaWheel: 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.
- **className** (`string`): Added to the root element.

## Accessibility

- 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.

## Source

### stamina-wheel/stamina-wheel.tsx

```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,
  type Wedge,
} from './geometry';
import { useAutoplay } from './use-autoplay';
import styles from './stamina-wheel.module.css';

/*
 * Stamina wheel: the green wheel that shows up beside a player character while they sprint, climb
 * or jump, and tells at a glance how much effort is left.
 *
 * - StaminaGauge is the wheel alone, driven by props: `value` wheels of stamina out of `wheels`
 *   (one to three; the second and third are thin rings around the first), `bonus` temporary
 *   wheels drawn small and yellow to one side, spent before the green, and a red wedge at the
 *   drain front for what an action is about to cost (`cost`) or has just cost (`spent`).
 * - The empty part of a wheel is a dark wedge growing clockwise from 12 o'clock; the outer ring
 *   drains first and the main wheel last. Each ring is one circle stroked as wide as the ring,
 *   and each wedge a dash of it (geometry.ts), so nothing is ever re-pathed.
 * - Out of stamina, the player is exhausted: the wheel turns red and pulses until it is full
 *   again. Low on it, it glows red. Full and idle, it settles with a small pop, waits a moment
 *   and fades away; the first use brings it straight back.
 * - StaminaWheel is a playground around it: the wheel alone in the middle of a plain stage, and
 *   under it the four actions that spend stamina (hold to sprint, jump, hold to charge a dash,
 *   eat for bonus stamina), by buttons or keys. While on screen and untouched, a short scripted
 *   run plays them by itself. Give it `value` and it is a plain display of that value instead.
 * - With no game scene behind it, the wheel brings its own footing, as a HUD does: an opaque dark
 *   track and a thin dark rim around every ring, so the green reads on a white page as well as on
 *   a black one. Everything is drawn as sharp SVG strokes; the only soft things are glows, as
 *   drop shadows behind them. Colours are tokens in the CSS: the green, red and yellow are the
 *   same on any scheme, the track and the controls follow the page's color-scheme, or the
 *   colorScheme prop.
 */

/** How long a full, idle wheel stays before it fades, in ms. */
const LINGER = 650;
/** The spending and refilling rates, in wheels per second, and the pause before refilling. */
const SPRINT = 0.5;
const REFILL = 0.6;
const REFILL_TIRED = 0.42;
const REFILL_DELAY = 0.5;
/** What a jump costs, what a fully charged dash costs and how long it takes to charge, in s. */
const JUMP = 0.15;
const DASH = 0.25;
const CHARGE = 0.5;
/** Eating adds a bonus wheel, up to this many. */
const MAX_BONUS = 2;
/** A spent chunk stays red this long, then shrinks to the front over SHRINK, in s. */
const HOLD = 0.22;
const SHRINK = 0.38;
/** Below this many wheels, while spending, the wheel warns. */
const LOW = 0.2;

type Side = 'left' | 'right';

export type StaminaGaugeProps = {
  /** Stamina left, in wheels: from 0 to `wheels`. */
  value: number;
  /** How many wheels the player has: the main one, plus one or two rings around it. */
  wheels?: 1 | 2 | 3;
  /** Temporary bonus stamina, in wheels (up to 2 drawn): small yellow wheels, spent first. */
  bonus?: number;
  /** Which side the bonus wheels sit on: left beside a character, right in a menu. */
  bonusSide?: Side;
  /** What a held action is about to take from the green, in wheels: a red wedge into what is left. */
  cost?: number;
  /** What was just taken from the green, in wheels: a red wedge behind the drain front. */
  spent?: number;
  /** Out of stamina until full: the wheel turns red and pulses. */
  exhausted?: boolean;
  /** Stamina is being spent right now (bonus included): keeps an auto wheel shown while full. */
  active?: boolean;
  /** Shown or hidden; 'auto' hides it once full and idle, as a game does. */
  visible?: boolean | 'auto';
  /** The main wheel's size in px (its 90-unit frame); the CSS default is 90, 116 on wide stages. */
  size?: number;
  /** What a screen reader calls it. */
  label?: string;
  className?: string;
};

/** One ring of the wheel: its dark rim, the dark track, what is left, and the red wedge on top. */
function RingArt({
  ring,
  left,
  red,
  part,
  className,
}: {
  ring: Ring;
  left: Wedge | null;
  red: Wedge | null;
  part: string;
  className?: string;
}) {
  const circle = { r: ring.r, cx: 0, cy: 0, strokeWidth: ring.width, pathLength: 1 } as const;
  const empty: Wedge = { start: 0, end: left ? left.start : 1 };
  return (
    <g data-part={part} className={className}>
      <circle {...circle} strokeWidth={ring.width + EDGE * 2} className={styles.edge} />
      <circle {...circle} className={styles.track} data-part="track" />
      {left && <circle {...circle} className={styles.left} data-part="left" style={dash(left)} />}
      {red && <circle {...circle} className={styles.red} style={dash(red)} />}
      {/* Invisible boxes: the ring's whole extent, its empty wedge and its red wedge, so each can
          be measured and pointed at (an SVG circle's box ignores its stroke and its dash). */}
      <rect {...wedgeBox(ring, { start: 0, end: 1 })} fill="none" />
      {empty.end > 0.0005 && <rect {...wedgeBox(ring, empty)} fill="none" data-part="empty" />}
      {red && <rect {...wedgeBox(ring, red)} fill="none" data-part="wedge" />}
    </g>
  );
}

export function StaminaGauge({
  value,
  wheels = 1,
  bonus = 0,
  bonusSide = 'left',
  cost = 0,
  spent = 0,
  exhausted = false,
  active = false,
  visible = 'auto',
  size,
  label = 'Stamina',
  className = '',
}: StaminaGaugeProps) {
  const max = Math.min(3, Math.max(1, Math.round(wheels)));
  const now = Math.min(max, Math.max(0, value));
  const extra = Math.min(MAX_BONUS, Math.max(0, bonus));
  const full = now >= max - 0.0005;
  const needed = !full || active || cost > 0 || spent > 0 || exhausted;

  // Auto: shown while needed, and LINGER ms more once full (with a pop as it fills up).
  const [idle, setIdle] = useState(!needed);
  const [was, setWas] = useState(needed);
  if (was !== needed) {
    setWas(needed);
    if (needed) setIdle(false);
  }
  const wheel = useRef<HTMLSpanElement>(null);
  const first = useRef(true);
  useEffect(() => {
    if (first.current) {
      first.current = false;
      if (!needed) return;
    }
    if (needed) return;
    // Filled up: a small pop and a green flash, unless the reader asks for less motion.
    if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches)
      wheel.current?.animate(
        [
          { scale: '1', filter: 'none' },
          { scale: '1.08', filter: 'drop-shadow(0 0 calc(var(--size, 90px) * 0.08) var(--glow))' },
          { scale: '1', filter: 'none' },
        ],
        { duration: 420, easing: 'cubic-bezier(0.3, 0.7, 0.4, 1)' },
      );
    const timer = window.setTimeout(() => setIdle(true), LINGER);
    return () => window.clearTimeout(timer);
  }, [needed]);
  const shown = visible === 'auto' ? needed || !idle : visible;

  // A bonus wheel that empties pops out where it was, as the far one slides into its place.
  const slots = bonusSlots(extra);
  const [count, setCount] = useState(slots.length);
  const [leaving, setLeaving] = useState<{ id: number; slot: number; key: number }[]>([]);
  if (count !== slots.length) {
    setCount(slots.length);
    if (slots.length < count) {
      const gone = Array.from({ length: count - slots.length }, (_, index) => ({
        id: slots.length + index,
        slot: count - slots.length - 1 - index,
        key: Date.now() + index,
      }));
      setLeaving((list) => [...list, ...gone]);
    }
  }
  useEffect(() => {
    if (!leaving.length) return;
    const timer = window.setTimeout(() => setLeaving([]), 260);
    return () => window.clearTimeout(timer);
  }, [leaving]);

  // The red wedge sits on the green, around the drain front: what a held action will take
  // ahead of it, what was just spent behind it.
  const onBonus = extra > 0.0005;
  const red: [number, number] | null = cost > 0 || spent > 0 ? [now - cost, now + spent] : null;

  const place = (slot: number) => {
    const { x, y } = bonusAt(slot, bonusSide, max);
    return `translate(${x}px, ${y}px)`;
  };

  const percent = Math.round((now / max) * 100);
  const text =
    `${percent}% of ${max} ${max === 1 ? 'wheel' : 'wheels'}` +
    (onBonus ? `, plus ${extra.toFixed(1)} bonus` : '') +
    (exhausted ? ', exhausted' : now < LOW && !full ? ', low' : '');

  return (
    <div
      className={`${styles.gauge} ${className}`}
      data-part="gauge"
      role="meter"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={Number(now.toFixed(2))}
      aria-valuetext={text}
      data-shown={shown}
      data-exhausted={exhausted || undefined}
      data-low={(!exhausted && now < LOW && !full && !onBonus) || undefined}
      data-side={bonusSide}
      style={size ? ({ '--size': `${size}px` } as CSSProperties) : undefined}
    >
      <span ref={wheel} className={styles.wheel}>
        <svg
          className={styles.art}
          viewBox={`${VIEW.x} ${VIEW.y} ${VIEW.width} ${VIEW.height}`}
          aria-hidden="true"
          focusable="false"
        >
          <g>
            {RINGS.slice(0, max).map((ring, index) => (
              <RingArt
                key={index}
                ring={ring}
                part={index ? `ring-${index + 1}` : 'wheel'}
                left={fill(now, index)}
                red={red && span(red[0], red[1], index)}
              />
            ))}
            {slots.map(({ id, slot, base }) => (
              <g key={id} className={styles.slot} style={{ transform: place(slot) }}>
                <RingArt
                  ring={BONUS}
                  part="bonus"
                  className={styles.bonus}
                  left={fill(extra, base)}
                  red={null}
                />
              </g>
            ))}
            {leaving.map(({ key, slot }) => (
              <g key={key} className={styles.slot} style={{ transform: place(slot) }}>
                <RingArt
                  ring={BONUS}
                  part="bonus-leaving"
                  className={styles.leaving}
                  left={null}
                  red={null}
                />
              </g>
            ))}
          </g>
        </svg>
      </span>
    </div>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The playground                                                                              */
/* ------------------------------------------------------------------------------------------ */

type Action = 'sprint' | 'jump' | 'dash' | 'eat';

/** The world's state: stamina, bonus, the red wedge's two parts, and what is held. */
type World = {
  value: number;
  bonus: number;
  exhausted: boolean;
  /** Seconds since stamina was last spent, for the pause before refilling. */
  rest: number;
  /** The last chunk spent, and how long ago. */
  spent: number;
  spentAge: number;
  /** How long a dash has been charging, in s (-1: not charging). */
  charge: number;
  sprint: boolean;
};

const ACTIONS: { action: Action; glyph: string; label: string; key: string; hold?: boolean }[] = [
  { action: 'sprint', glyph: 'B', label: 'Sprint', key: 'Shift', hold: true },
  { action: 'jump', glyph: 'X', label: 'Jump', key: 'Space' },
  { action: 'dash', glyph: 'ZR', label: 'Dash', key: 'F', hold: true },
  { action: 'eat', glyph: 'Y', label: 'Eat', key: 'E' },
];

/**
 * The scripted run, in s from its start: presses and releases. It sprints off the bonus left from
 * before and into the main wheel, jumps twice and charges a dash (red wedges), eats twice, then sprints through both bonus
 * wheels and the green until exhausted, and recovers.
 */
const SCRIPT: [number, Action, boolean][] = [
  [1.2, 'sprint', true],
  [3.4, 'sprint', false],
  [3.6, 'jump', true],
  [4.3, 'jump', true],
  [4.9, 'dash', true],
  [5.6, 'dash', false],
  [7.0, 'eat', true],
  [7.5, 'eat', true],
  [8.4, 'sprint', true],
  [17.0, 'sprint', false],
];
const SCRIPT_LENGTH = 23.5;

export function StaminaWheel({
  wheels = 2,
  value,
  bonus,
  cost,
  spent,
  exhausted,
  bonusSide = 'left',
  size,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** How many wheels the player has, one to three. */
  wheels?: 1 | 2 | 3;
  /** Shows this stamina instead of playing: the playground becomes a plain display. */
  value?: number;
  /** With `value`: bonus stamina, in wheels. */
  bonus?: number;
  /** With `value`: what a held action is about to cost, in wheels. */
  cost?: number;
  /** With `value`: what was just spent, in wheels. */
  spent?: number;
  /** With `value`: out of stamina until full. */
  exhausted?: boolean;
  /** Which side the bonus wheels sit on. */
  bonusSide?: Side;
  /** The main wheel's size in px; without it, 104, 128 from a stage 400 px wide, 160 from 640. */
  size?: number;
  /** While on screen and untouched, a short scripted run plays the actions by itself. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, take } = useAutoplay<HTMLDivElement>(6000);
  const controlled = value !== undefined;
  const [max, setMax] = useState(wheels);
  const [prevWheels, setPrevWheels] = useState(wheels);
  if (prevWheels !== wheels) {
    setPrevWheels(wheels);
    setMax(wheels);
  }
  /** The world, in a ref the clock writes; `frame` is its copy for drawing. */
  const world = useRef<World>({
    value: Math.min(wheels, 1.3),
    bonus: 0.65,
    exhausted: false,
    rest: 0,
    spent: 0,
    spentAge: 0,
    charge: -1,
    sprint: false,
  });
  const [frame, setFrame] = useState(() => ({ ...world.current }));
  const [held, setHeld] = useState<Partial<Record<Action, boolean>>>({});
  const [message, setMessage] = useState('');
  /** Real input since the last scripted run: only then are events announced. */
  const reader = useRef(false);
  const maxRef = useRef(max);
  maxRef.current = max;
  /** Wakes the clock when it has stopped. */
  const wake = useRef<() => void>(() => {});

  /** Announces `text`, then clears it, so the same news can be announced again. */
  const quiet = useRef(0);
  const say = (text: string) => {
    if (!reader.current) return;
    setMessage(text);
    window.clearTimeout(quiet.current);
    quiet.current = window.setTimeout(() => setMessage(''), 3000);
  };
  useEffect(() => () => window.clearTimeout(quiet.current), []);

  /** Spends `amount` wheels, bonus first. False when exhausted. */
  const spend = (amount: number, chunk: boolean) => {
    const w = world.current;
    if (w.exhausted) return false;
    const fromBonus = Math.min(w.bonus, amount);
    w.bonus -= fromBonus;
    w.value = Math.max(0, w.value - (amount - fromBonus));
    w.rest = 0;
    // Only what came off the green leaves a red wedge: a bonus wheel just shrinks.
    if (chunk && amount > fromBonus) {
      w.spent = (w.spentAge < HOLD + SHRINK ? w.spent : 0) + amount - fromBonus;
      w.spentAge = 0;
    }
    if (w.value <= 0 && w.bonus <= 0) {
      w.exhausted = true;
      w.sprint = false;
      w.charge = -1;
      say('Exhausted. Stamina refills before you can act again.');
    }
    return true;
  };

  const press = (action: Action, down: boolean) => {
    const w = world.current;
    if (action === 'sprint') w.sprint = down && !w.exhausted;
    if (action === 'dash') {
      if (down && w.charge < 0 && !w.exhausted) w.charge = 0;
      if (!down && w.charge >= 0) {
        spend(DASH * Math.min(1, w.charge / CHARGE), true);
        w.charge = -1;
      }
    }
    if (action === 'jump' && down) spend(JUMP, true);
    if (action === 'eat' && down) {
      if (w.bonus < MAX_BONUS - 0.001) say('Bonus stamina added.');
      w.bonus = Math.min(MAX_BONUS, w.bonus + 1);
      // Shows the wheel for the pickup, even when it is full and hidden.
      w.rest = 0;
    }
    if (action === 'sprint' || action === 'dash') setHeld((h) => ({ ...h, [action]: down }));
    else if (down) {
      setHeld((h) => ({ ...h, [action]: true }));
      window.setTimeout(() => setHeld((h) => ({ ...h, [action]: false })), 140);
    }
    wake.current();
  };

  // The clock: steps the world while anything moves, and stops when it is at rest.
  useEffect(() => {
    if (controlled || !visible) return;
    let raf = 0;
    let last = 0;
    const step = (time: number) => {
      const dt = last ? Math.min(0.05, (time - last) / 1000) : 0;
      last = time;
      const w = world.current;
      const top = maxRef.current;
      if (w.value > top) w.value = top;
      if (w.sprint && !w.exhausted) spend(SPRINT * dt, false);
      else if (w.charge >= 0) w.charge += dt;
      else w.rest += dt;
      if (w.exhausted) {
        w.value = Math.min(top, w.value + REFILL_TIRED * dt);
        if (w.value >= top) {
          w.exhausted = false;
          say('Stamina full.');
        }
      } else if (!w.sprint && w.charge < 0 && w.rest > REFILL_DELAY) {
        w.value = Math.min(top, w.value + REFILL * dt);
      }
      if (w.spent > 0) {
        w.spentAge += dt;
        if (w.spentAge > HOLD + SHRINK) w.spent = 0;
      }
      setFrame({ ...w });
      const busy =
        w.sprint ||
        w.charge >= 0 ||
        w.exhausted ||
        w.value < top ||
        w.spent > 0 ||
        w.rest <= REFILL_DELAY ||
        dt === 0;
      raf = busy ? requestAnimationFrame(step) : 0;
    };
    wake.current = () => {
      if (!raf) {
        last = 0;
        raf = requestAnimationFrame(step);
      }
    };
    raf = requestAnimationFrame(step);
    return () => {
      cancelAnimationFrame(raf);
      wake.current = () => {};
    };
  }, [controlled, visible, max]);

  // The scripted run: replays SCRIPT on a loop while playing, letting go of whatever it held.
  const run = autoplay && playing && !controlled;
  useEffect(() => {
    if (!run) return;
    reader.current = false;
    const timers: number[] = [];
    let pressed: Action[] = [];
    const loop = () => {
      for (const [at, action, down] of SCRIPT) {
        timers.push(
          window.setTimeout(() => {
            pressed = down ? [...pressed, action] : pressed.filter((a) => a !== action);
            press(action, down);
          }, at * 1000),
        );
      }
      timers.push(window.setTimeout(loop, SCRIPT_LENGTH * 1000));
    };
    loop();
    return () => {
      timers.forEach((timer) => window.clearTimeout(timer));
      for (const action of pressed)
        if (action === 'sprint' || action === 'dash') press(action, false);
    };
  }, [run]);

  /** Real input: the reader takes over from the script. */
  const real = () => {
    reader.current = true;
    take();
  };

  const keyAction = (event: ReactKeyboardEvent): Action | null => {
    if (event.key === 'Shift') return 'sprint';
    if (event.key === ' ' && !(event.target instanceof HTMLButtonElement)) return 'jump';
    if (event.key === 'f' || event.key === 'F') return 'dash';
    if (event.key === 'e' || event.key === 'E') return 'eat';
    return null;
  };
  const onKeyDown = (event: ReactKeyboardEvent) => {
    const action = keyAction(event);
    if (!action || event.repeat || event.metaKey || event.ctrlKey || event.altKey) return;
    event.preventDefault();
    real();
    press(action, true);
  };
  const onKeyUp = (event: ReactKeyboardEvent) => {
    const action = keyAction(event);
    if (!action) return;
    if (action === 'sprint' || action === 'dash') press(action, false);
  };

  /** A hold button: pointer down to release, or Space / Enter down to up. */
  const holdProps = (action: Action) => ({
    onPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => {
      if (event.button !== 0) return;
      event.currentTarget.setPointerCapture(event.pointerId);
      real();
      press(action, true);
    },
    onPointerUp: () => press(action, false),
    onPointerCancel: () => press(action, false),
    onLostPointerCapture: () => press(action, false),
    onKeyDown: (event: ReactKeyboardEvent) => {
      if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
        event.preventDefault();
        event.stopPropagation();
        real();
        press(action, true);
      }
    },
    onKeyUp: (event: ReactKeyboardEvent) => {
      if (event.key === ' ' || event.key === 'Enter') {
        event.stopPropagation();
        press(action, false);
      }
    },
    onBlur: () => press(action, false),
    onContextMenu: (event: { preventDefault: () => void }) => event.preventDefault(),
  });

  const f = frame;
  const shrink = f.spentAge > HOLD ? 1 - Math.min(1, (f.spentAge - HOLD) / SHRINK) : 1;
  const ease = shrink * shrink * (3 - 2 * shrink);
  const gauge = controlled ? (
    <StaminaGauge
      value={value}
      wheels={max}
      bonus={bonus}
      cost={cost}
      spent={spent}
      exhausted={exhausted}
      bonusSide={bonusSide}
      visible
    />
  ) : (
    <StaminaGauge
      value={f.value}
      wheels={max}
      bonus={f.bonus}
      cost={f.charge >= 0 ? Math.max(0, DASH * Math.min(1, f.charge / CHARGE) - f.bonus) : 0}
      spent={f.spent * ease}
      exhausted={f.exhausted}
      active={f.sprint || f.charge >= 0 || f.rest < REFILL_DELAY}
      bonusSide={bonusSide}
    />
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      style={size ? ({ '--size-prop': `${size}px` } as CSSProperties) : undefined}
      onKeyDown={controlled ? undefined : onKeyDown}
      onKeyUp={controlled ? undefined : onKeyUp}
    >
      <div
        className={styles.stage}
        data-part="stage"
        role="group"
        aria-label="Stamina playground: hold Shift to sprint, Space to jump, hold F to charge a dash, E to eat"
        tabIndex={controlled ? undefined : 0}
      >
        {gauge}
      </div>
      {!controlled && (
        <div className={styles.controls} data-part="controls">
          <div className={styles.actions} role="group" aria-label="Actions">
            {ACTIONS.map(({ action, glyph, label, key, hold }) => (
              <button
                key={action}
                type="button"
                className={styles.action}
                data-part="action"
                data-active={held[action] || undefined}
                aria-keyshortcuts={key === 'Space' ? 'Space' : key}
                aria-pressed={hold ? Boolean(held[action]) : undefined}
                title={`${hold ? 'Hold' : 'Press'} ${key}`}
                {...(hold
                  ? holdProps(action)
                  : {
                      onClick: () => {
                        real();
                        press(action, true);
                      },
                    })}
              >
                <span
                  className={styles.glyph}
                  data-part="glyph"
                  data-wide={glyph.length > 1 || undefined}
                >
                  {glyph}
                </span>
                {label}
              </button>
            ))}
          </div>
          <div className={styles.wheels} role="group" aria-label="Wheels">
            {([1, 2, 3] as const).map((count) => (
              <button
                key={count}
                type="button"
                className={styles.count}
                data-part="count"
                aria-pressed={max === count}
                aria-label={`${count} ${count === 1 ? 'wheel' : 'wheels'}`}
                onClick={() => {
                  real();
                  setMax(count);
                  wake.current();
                }}
              >
                {count}
              </button>
            ))}
          </div>
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### stamina-wheel/geometry.ts

```tsx
// 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];
/** The space between the wheel's outer edge and the nearest bonus wheel. */
export const BONUS_GAP = 7.3125;
/** From one bonus wheel's middle to the next, with one, two or three wheels. */
const BONUS_STEP = [36.625, 36.75, 37];
/** In menus (bonus on the right) the bonus wheels sit this much higher. */
const RIGHT_LIFT = 1.25;

/** The drawing's box: wide enough for two bonus wheels on either side, main wheel in the middle. */
export const VIEW = { x: -121, y: -46, width: 242, height: 92 };
/** The dark rim drawn around every ring, so it reads on a light page: its width on each side. */
export const EDGE = 1;

/** A wedge, from `start` to `end`, fractions of a turn clockwise from 12 o'clock. */
export type Wedge = { start: number; end: number };

const clamp = (value: number, low = 0, high = 1) => Math.min(high, Math.max(low, value));

/**
 * What is left of one wheel holding the stamina range [base, base + 1]: the empty part grows
 * clockwise from 12 o'clock, so what is left ends there.
 */
export function fill(value: number, base: number): Wedge | null {
  const left = clamp(value - base);
  return left > 0 ? { start: 1 - left, end: 1 } : null;
}

/** The part of the stamina range [low, high] that falls on the wheel holding [base, base + 1]. */
export function span(low: number, high: number, base: number): Wedge | null {
  const from = clamp(low - base);
  const to = clamp(high - base);
  return to - from > 0.0005 ? { start: 1 - to, end: 1 - from } : null;
}

/** A wedge as the dash of a circle whose length is 1 (pathLength), started at 12 o'clock. */
export function dash(wedge: Wedge) {
  const length = wedge.end - wedge.start;
  if (length >= 0.9995) return { strokeDasharray: undefined, strokeDashoffset: undefined };
  return {
    strokeDasharray: `${length.toFixed(5)} 2`,
    strokeDashoffset: (-wedge.start).toFixed(5),
  };
}

/** Where the bonus wheel `slot` sits (0 is the nearest), beside a wheel of `wheels` wheels. */
export function bonusAt(slot: number, side: 'left' | 'right', wheels: number) {
  const index = Math.min(3, Math.max(1, wheels)) - 1;
  const x = OUTER[index] + BONUS_GAP + BONUS.r + BONUS.width / 2 + slot * BONUS_STEP[index];
  return side === 'left' ? { x: -x, y: 0 } : { x, y: -RIGHT_LIFT };
}

/**
 * How many bonus wheels `bonus` stamina takes, and the range each holds: the nearest one is spent
 * first, so slot 0 holds the top of the range.
 */
export function bonusSlots(bonus: number) {
  const count = Math.max(0, Math.ceil(bonus - 0.0005));
  return Array.from({ length: count }, (_, slot) => ({
    slot,
    /** Stable through spending: counted from the far end, so the far wheel keeps its id. */
    id: count - 1 - slot,
    base: count - 1 - slot,
  }));
}

/**
 * The box a wedge of a ring covers on screen, in drawing units: its two ends on both edges, plus
 * the outer edge at 12, 3, 6 and 9 o'clock when the wedge passes them. Drawn as an invisible rect
 * so the docs can point at the wedge (an SVG circle's box ignores both its stroke and its dash).
 */
export function wedgeBox(ring: Ring, wedge: Wedge) {
  const inner = ring.r - ring.width / 2;
  const outer = ring.r + ring.width / 2;
  if (wedge.end - wedge.start >= 0.9995)
    return { x: -outer, y: -outer, width: 2 * outer, height: 2 * outer };
  const at = (turn: number, radius: number) => ({
    x: radius * Math.sin(turn * 2 * Math.PI),
    y: -radius * Math.cos(turn * 2 * Math.PI),
  });
  const points = [inner, outer].flatMap((radius) => [
    at(wedge.start, radius),
    at(wedge.end, radius),
  ]);
  for (const turn of [0.25, 0.5, 0.75])
    if (wedge.start < turn && turn < wedge.end) points.push(at(turn, outer));
  const xs = points.map((point) => point.x);
  const ys = points.map((point) => point.y);
  const x = Math.min(...xs);
  const y = Math.min(...ys);
  return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
}
```

### stamina-wheel/stamina-wheel.module.css

```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);
  vertical-align: middle;
}

/* The drawing is wider than the wheel's box: room for the bonus wheels on either side. */
.wheel {
  position: absolute;
  top: calc(var(--size, 90px) / -90);
  left: 50%;
  width: calc(var(--size, 90px) * 242 / 90);
  height: calc(var(--size, 90px) * 92 / 90);
  translate: -50% 0;
  opacity: 0;
  scale: 0.86;
  transition:
    opacity 320ms ease-in,
    scale 320ms ease-in;
}
.gauge[data-shown='true'] .wheel {
  opacity: 1;
  scale: 1;
  transition:
    opacity 120ms ease-out,
    scale 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.art {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
@supports (color: light-dark(#fff, #000)) {
  .gauge {
    --track: light-dark(#3a403c, #333935);
    --edge: light-dark(rgb(10 14 12 / 0.75), rgb(0 0 0 / 0.9));
  }
}

.art circle {
  fill: none;
  transform: rotate(-90deg);
  transform-box: fill-box;
  transform-origin: center;
}
.edge {
  stroke: var(--edge);
}
.track {
  stroke: var(--track);
}
.left {
  stroke: var(--green);
  transition: stroke 160ms ease-out;
}
.red {
  stroke: var(--red);
}
.bonus .left {
  stroke: var(--yellow);
}

/* Exhausted: the green turns red and the wheel pulses until it is full again. Low: a quicker red
   glow while it is spent. */
.gauge[data-exhausted] .left {
  stroke: var(--red);
}
.gauge[data-exhausted] .wheel {
  animation: pulse 900ms ease-in-out infinite;
}
.gauge[data-low] .wheel {
  animation: pulse 420ms ease-in-out infinite;
}
@keyframes pulse {
  0%,
  100% {
    filter: drop-shadow(0 0 0 transparent);
  }
  50% {
    filter: drop-shadow(0 0 calc(var(--size, 90px) * 0.09) var(--warn));
  }
}

/* A bonus wheel slides to its slot, pops in when eaten, and pops out when empty. */
.slot {
  transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bonus,
.leaving {
  transform-box: fill-box;
  transform-origin: center;
}
.bonus {
  animation: pop-in 320ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.leaving {
  animation: pop-out 240ms ease-in both;
}
@keyframes pop-in {
  from {
    transform: scale(0);
  }
}
@keyframes pop-out {
  30% {
    transform: scale(1.12);
    opacity: 1;
  }
  to {
    transform: scale(0);
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .wheel,
  .gauge[data-shown='true'] .wheel {
    scale: 1;
    transition: opacity 160ms linear;
  }
  .gauge[data-exhausted] .wheel,
  .gauge[data-low] .wheel {
    animation: none;
    filter: drop-shadow(0 0 calc(var(--size, 90px) * 0.07) var(--warn));
  }
  .bonus,
  .leaving {
    animation-duration: 1ms;
  }
  .slot {
    transition: none;
  }
}

/* ---------------------------------------------------------------------------------------- */
/* The playground                                                                            */
/* ---------------------------------------------------------------------------------------- */

.root {
  /* The buttons, as quiet lab chips: a surface, a hairline, and a round inverted button glyph. */
  --chip: #fff;
  --chip-active: #ececec;
  --chip-line: rgb(0 0 0 / 0.1);
  --chip-ink: #1b1d1c;
  --glyph: #1d1f1e;
  --glyph-ink: #fff;
  --accent: #0b8f37;
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  width: 100%;
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --chip: light-dark(#fff, #1f2120);
    --chip-active: light-dark(#ececec, #2e3130);
    --chip-line: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --chip-ink: light-dark(#1b1d1c, #eceeed);
    --glyph: light-dark(#1d1f1e, #eceeed);
    --glyph-ink: light-dark(#fff, #151716);
    --accent: light-dark(#0b8f37, #13ff59);
    --focus: light-dark(#387bff, #7aa7ff);
  }
}
.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;
}

/* The stage: transparent, the wheel in its middle, with room around it for the bonus wheels. */
.stage {
  --size: var(--size-prop, 104px);
  display: grid;
  place-items: center;
  min-height: calc(var(--size) * 1.9);
  border-radius: 14px;
  outline: none;
  line-height: 0;
}
@container (min-width: 400px) {
  .stage {
    --size: var(--size-prop, 128px);
  }
}
@container (min-width: 640px) {
  .stage {
    --size: var(--size-prop, 160px);
  }
}
.stage:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}

/* The buttons, under the stage. */
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 8px 10px;
  padding: 4px 8px 8px;
}
.actions,
.wheels {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
}
.action,
.count {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 36px;
  padding: 0 12px 0 5px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-line);
  color: var(--chip-ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
  transition:
    background-color 120ms ease,
    transform 120ms ease;
}
.action[data-active],
.count[aria-pressed='true'] {
  background: var(--chip-active);
}
.action[data-active] {
  transform: scale(0.96);
}
.action:focus-visible,
.count:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.glyph {
  display: grid;
  place-items: center;
  min-width: 26px;
  height: 26px;
  padding: 0 4px;
  border-radius: 13px;
  background: var(--glyph);
  color: var(--glyph-ink);
  font-size: 13px;
  font-weight: 800;
  transition: transform 120ms ease;
}
.glyph[data-wide] {
  font-size: 11px;
}
.action[data-active] .glyph {
  transform: scale(0.88);
}
.count {
  justify-content: center;
  min-width: 36px;
  padding: 0;
}
.count[aria-pressed='true'] {
  box-shadow: inset 0 0 0 2px var(--accent);
}

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

### stamina-wheel/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. `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]) => {
        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 };
}
```