# Heat gauge

> A round HUD thermometer: a needle on a spring over cold, comfortable and hot zones, with frost and heat-haze warnings.

The small dial a game HUD keeps in its corner to tell the player whether the spot they stand on will hurt them. The needle swings to the temperature on a spring and never sits quite still; in the cyan arc frost grows around the rim, in the orange arc a haze rises off the dial. Warm or cool gear keeps the reading but silences the warning, and the unit flips between °F and °C.

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

Category: Games · HUD.

## Anatomy

1. **Banner**: A HUD banner over the dial, on a dark plate that reads on light and dark pages: the demo’s place, or the zone (Too cold, Comfortable, Too hot). It slides in when it changes. `bare` leaves it out.
2. **Dial**: The `role="meter"`: a near-black disc `size` px wide, the same on both schemes, alone on the page’s stage. Its art box is half as large again, so warnings spill past the rim.
3. **Rim**: What lets the dark disc read on any page: a thin light ring round it, strongest on a dark page, over a soft drop shadow that is strongest on a light one.
4. **Arc**: An outer band and a thin inner track over 270°, cut into 30° cells: cyan below `coldBelow`, navy in between, orange above `hotAbove`.
5. **Needle**: A cyan hand on a round hub, swung by a spring (one overshoot, about 0.6 s to settle) with a tremor that tells the zone.
6. **Unit**: A degree ring and an F or a C under the hub, drawn as strokes; switching it flips the letter and leaves the needle still.
7. **Frost**: The cold warning: eighteen shards grow out of the rim one after another, with a dotted rime ring and a breathing cyan glow.
8. **Controls**: Small neutral chips under the dial, in the page’s scheme: the three zones as a radio group, a temperature slider with its readout, the unit and the Gear toggle.

## When to use

- A game HUD that tells the player whether the place they stand on will hurt them: too cold, comfortable or too hot.
- A reading that matters by its zone more than its number: three coloured spans of an arc, with the edges on fixed cuts.
- A warning that has to be felt at a glance, without words: frost grows on the rim in the cold, a haze rises in the heat.
- Gear or a buff that cancels the danger but not the reading: `shielded` keeps the needle and silences the warning.
- A small corner dial (`bare`, 50 px in the kit) fed by the game loop through `temperature`.

## When not to use

- An exact figure the reader has to read or compare. Show the number as text, or use a slider with a readout.
- A value with no danger zones, such as a volume or a progress. Use a progress bar or a plain meter.
- A business dashboard or a settings page: the frost and haze read as a game. Use a gauge chart with labelled bands.
- More than three zones, or zones that are not ordered cold to hot. Use a segmented bar with a legend.
- A health, stamina or charge bar that drains and fills. Use a bar or a ring made for depletion.

## Installation

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

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

export function Hud({ celsius, warmGear }: { celsius: number; warmGear: boolean }) {
  return <HeatGauge bare temperature={celsius} shielded={warmGear} unit="C" size={50} />;
}
```

## States

- **Comfortable**: `temperature={18}`: the needle in the navy cells with a faint tremor; no warning.
- **Too cold**: Below `coldBelow`: frost on the rim, the cyan arc glows, and the needle shivers.
- **Too hot**: Above `hotAbove`: a haze rises off the dial, the orange arc glows and the needle wobbles.
- **Resisted**: `shielded`: the needle still reads too cold, but no frost, glow or shiver plays.
- **Celsius**: `unit="C"`: the glyph and the readout change; the needle does not move.
- **Own scale**: `coldBelow={10}` and `hotAbove={24}`: the zones keep their arcs, so 28 °C already reads too hot.
- **Bare, kit size**: `bare size={50}`: just the dial, for a HUD of your own, in each of the three zones.
- **Light**: `colorScheme="light"`: white chips; the dark disc stands on a soft drop shadow.
- **Dark**: `colorScheme="dark"`, or a dark page: dark chips; a light rim keeps the disc apart from the page.

## How it works

- The dial is one SVG in a 100-unit box: a dark disc, an outer band and a thin inner track cut into 30-degree cells from 225 to 135 degrees, a needle on a round hub, and the unit glyph drawn as strokes. --size scales it all; the kit draws it at 50 px.
- The scale is three spans, each filling its own zone of the arc: below coldBelow the needle is in the cyan cells, between coldBelow and hotAbove in the navy ones, above hotAbove in the orange ones, so the zone edges always fall on the cuts at -60 and 60 degrees.
- The needle is a damped spring (stiffness 190, damping 15) stepped every frame, so a new reading swings in with one overshoot and settles in about 0.6 s. A tremor rides on top: faint when comfortable, a fast shiver in the cold, a slower wobble in the heat.
- Cold warning: eighteen frost shards grow out of the rim with staggered springy transitions, a rime ring appears, and a blurred copy of the cyan arc and a halo breathe. Hot warning: wisps rise off the dial through an feTurbulence displacement whose noise the frame loop scrolls upward, and the orange arc and halo breathe. Entering either zone pops the dial with the Web Animations API.
- With shielded (gear that resists the zone) the needle still reads but no warning plays. Switching the unit flips the glyph and leaves the needle where it is.
- The frame loop and the CSS animations pause off screen and in a hidden tab. Under reduced motion the needle jumps to its reading, nothing trembles and the warnings are still. While on screen and untouched, a short trek plays, named in the banner over the dial: a meadow, nightfall, a blizzard, warm gear, a desert at noon, an oasis.
- The dial is shown alone on the page’s own stage and keeps its in-game colours on both schemes. It carries its own edge, as a HUD does: a soft drop shadow (a radial gradient under the disc) that holds it on a light page and a thin light rim that keeps it apart from a dark one. The demo’s banner sits on a dark plate; the rim, the shadow, the frost’s outline and the small neutral chips are light-dark() tokens, so they follow the page’s color-scheme or the colorScheme prop.
- Everything is vector and stays sharp at any pixel ratio: the only blur is the warning glow, a layer under the sharp cells.

## API reference

- **temperature** (`number`): The temperature where the player stands, in °C. Set it to control the gauge; the demo is then off.
- **defaultTemperature** (`number`, default `18`): The temperature an uncontrolled gauge starts at, in °C.
- **onTemperatureChange** (`(temperature: number) => void`): Called when the reader changes the temperature with the chips or the slider.
- **unit** (`'F' | 'C'`): The unit under the needle. Set it to control it.
- **defaultUnit** (`'F' | 'C'`, default `'F'`): The unit an uncontrolled gauge starts with.
- **onUnitChange** (`(unit: 'F' | 'C') => void`): Called when the reader switches the unit.
- **shielded** (`boolean`): The player’s gear resists the current zone: the needle still reads, no warning plays. Set it to control it.
- **defaultShielded** (`boolean`, default `false`): Whether an uncontrolled gauge starts shielded.
- **onShieldedChange** (`(shielded: boolean) => void`): Called when the reader toggles the gear.
- **min** (`number`, default `-30`): The coldest reading, in °C: the needle’s stop at the bottom left.
- **coldBelow** (`number`, default `0`): Below this (°C) it is too cold: the needle is in the cyan cells.
- **hotAbove** (`number`, default `32`): Above this (°C) it is too hot: the needle is in the orange cells.
- **max** (`number`, default `60`): The hottest reading, in °C: the needle’s stop at the bottom right.
- **size** (`number`, default `180`): The dial’s width in px; the kit draws it at 50.
- **bare** (`boolean`, default `false`): Just the dial: no banner and no controls, to place in a HUD of your own.
- **label** (`string`, default `'Temperature'`): The gauge’s accessible name.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short trek plays on its own. Any real input hands the controls to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark chips, rim and shadow, or the reader’s; without it, the page’s color-scheme. The dial keeps its game colours.
- **className** (`string`): Added to the root element.

## Accessibility

- The dial is a meter: its value is the temperature in the current unit, and its aria-valuetext says the zone, as in “119 °F, too hot”, with “resisted” when the gear is on.
- The zones are a radio group (arrow keys, Home and End move and pick), the temperature is a native range input with the same text, and the unit and gear are buttons, the gear with aria-pressed. Every control has a visible focus ring.
- Changes made with the controls, and zone changes of a controlled gauge, are announced through a polite live region.
- The art and the banner are hidden from screen readers. Reduced motion stops the tremor, the spring, the haze and the breathing; the warnings are shown still.

## Source

### heat-gauge/heat-gauge.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { C, CELLS, CUTS, GLOWS, SHARDS, WISPS, angleOf, type HeatState } from './geometry';
import { useAutoplay } from './use-autoplay';
import styles from './heat-gauge.module.css';

export type { HeatState } from './geometry';

/*
 * Heat gauge: the thermometer of a game HUD. A small dark dial whose needle points at the
 * temperature where the player stands: in the light cyan arc on the left it is too cold to stay,
 * in the deep navy arc on top it is comfortable, in the orange arc on the right it is too hot.
 *
 * - The dial is one SVG in a 100-unit box: a dark disc, an outer band and a thin inner track cut
 *   into 30-degree cells, a needle on a round hub, and the unit (°F or °C) under the hub. --size
 *   scales it all; the kit draws it at 50 px. It is drawn alone, with no scene behind it, so it
 *   carries what it needs to read on any page, as a HUD does: a soft drop shadow under the disc
 *   and a thin light rim round it, both extra layers behind or around sharp vector shapes.
 * - The scale is three spans, each filling its own zone of the arc: below coldBelow the needle is in
 *   the cyan cells, above hotAbove in the orange ones, so the zone edges always fall on the cuts.
 * - The needle moves on a spring (a stiffness and a damping, stepped every frame), so a new reading
 *   swings in with one overshoot and settles in about 0.6 s. It is never quite still: a faint
 *   tremor when comfortable, a fast shiver in the cold, a slower wobble in the heat.
 * - Warnings: in the cold, frost shards grow around the rim one after another and the cyan arc
 *   glows and breathes; in the heat, a haze of wisps rises off the dial through a turbulence
 *   displacement that the frame loop scrolls, and the orange arc glows. Entering either zone pops
 *   the dial (Web Animations API). With `shielded` (gear that resists it) the needle still reads but
 *   no warning plays.
 * - Switching the unit flips the glyph; the needle stays where it is, as the temperature has not
 *   changed.
 * - The frame loop runs only on screen in a visible tab. Under reduced motion the needle jumps to
 *   its reading, nothing trembles, and the warnings are shown still.
 * - While on screen and untouched, a short trek plays on its own (a meadow, nightfall, a snowfield,
 *   warm gear, a desert at noon), named in a small HUD banner over the dial; any real input hands
 *   the controls to the reader, small neutral chips under the dial.
 */

export type HeatUnit = 'F' | 'C';

const STATES: HeatState[] = ['cold', 'normal', 'hot'];
const NAMES: Record<HeatState, string> = { cold: 'Cold', normal: 'Normal', hot: 'Hot' };
const WARNINGS: Record<HeatState, string> = {
  cold: 'Too cold',
  normal: 'Comfortable',
  hot: 'Too hot',
};
/** A temperature (°C) in each state that puts the needle where the kit draws it. */
export const HEAT_PRESETS: Record<HeatState, number> = { cold: -17.5, normal: 10.4, hot: 48.4 };

/** The spring: stiffness and damping per unit mass (about one overshoot of 15%, 0.6 s to settle). */
const STIFFNESS = 190;
const DAMPING = 15;

const toF = (c: number) => (c * 9) / 5 + 32;
const show = (c: number, unit: HeatUnit) => Math.round(unit === 'F' ? toF(c) : c);

/** The demo: a short trek, step by step, each held for `ms`. */
const SCRIPT: { place: string; t: number; ms: number; shielded?: boolean; unit?: HeatUnit }[] = [
  { place: 'Meadow', t: 18, ms: 1900 },
  { place: 'Nightfall', t: 9, ms: 1700 },
  { place: 'Snowfield', t: -6, ms: 1500 },
  { place: 'Blizzard', t: -19, ms: 2600 },
  { place: 'Warm gear on', t: -19, ms: 2000, shielded: true },
  { place: 'Valley', t: 13, ms: 1500, unit: 'C' },
  { place: 'Desert', t: 36, ms: 1500, unit: 'C' },
  { place: 'Desert at noon', t: 49, ms: 2700, unit: 'C' },
  { place: 'Oasis', t: 24, ms: 1900 },
];

export function HeatGauge({
  temperature,
  defaultTemperature = 18,
  onTemperatureChange,
  unit,
  defaultUnit = 'F',
  onUnitChange,
  shielded,
  defaultShielded = false,
  onShieldedChange,
  min = -30,
  coldBelow = 0,
  hotAbove = 32,
  max = 60,
  size = 180,
  bare = false,
  label = 'Temperature',
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The temperature where the player stands, in °C. Set it to control the gauge. */
  temperature?: number;
  /** The temperature an uncontrolled gauge starts at, in °C. */
  defaultTemperature?: number;
  /** Called when the reader changes the temperature with the controls. */
  onTemperatureChange?: (temperature: number) => void;
  /** The unit under the needle: Fahrenheit or Celsius. Set it to control it. */
  unit?: HeatUnit;
  /** The unit an uncontrolled gauge starts with. */
  defaultUnit?: HeatUnit;
  /** Called when the reader switches the unit. */
  onUnitChange?: (unit: HeatUnit) => void;
  /** The player's gear resists the current zone: the needle still reads, no warning plays. */
  shielded?: boolean;
  /** Whether an uncontrolled gauge starts shielded. */
  defaultShielded?: boolean;
  /** Called when the reader toggles the gear. */
  onShieldedChange?: (shielded: boolean) => void;
  /** The coldest reading, in °C: the needle's stop at the bottom left. */
  min?: number;
  /** Below this (°C) it is too cold: the needle is in the cyan cells. */
  coldBelow?: number;
  /** Above this (°C) it is too hot: the needle is in the orange cells. */
  hotAbove?: number;
  /** The hottest reading, in °C: the needle's stop at the bottom right. */
  max?: number;
  /** The dial's width in px; the kit draws it at 50. */
  size?: number;
  /** Just the dial: no banner and no controls, to place in a HUD of your own. */
  bare?: boolean;
  /** The gauge's accessible name. */
  label?: string;
  /** While on screen and untouched, a short trek plays on its own. Off when temperature is set. */
  autoplay?: boolean;
  /** Light or dark chips and dial edge, or the reader's ('system'); without it, the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(5000);
  const [ownT, setOwnT] = useState(defaultTemperature);
  const [ownUnit, setOwnUnit] = useState<HeatUnit>(defaultUnit);
  const [ownShielded, setOwnShielded] = useState(defaultShielded);
  const [step, setStep] = useState<number | null>(null);
  const [message, setMessage] = useState('');
  const dial = useRef<HTMLDivElement>(null);
  const needle = useRef<SVGGElement>(null);
  const shift = useRef<SVGFEOffsetElement>(null);
  const radios = useRef<(HTMLButtonElement | null)[]>([]);
  const ids = useId();

  const demoOn = autoplay && playing && temperature === undefined && !bare;
  const beat = demoOn && step !== null ? SCRIPT[step] : null;
  const t = temperature ?? beat?.t ?? ownT;
  const u = unit ?? beat?.unit ?? (beat ? 'F' : ownUnit);
  const guarded = shielded ?? (beat ? !!beat.shielded : ownShielded);
  const state: HeatState = t < coldBelow ? 'cold' : t > hotAbove ? 'hot' : 'normal';
  const target = angleOf(t, min, coldBelow, hotAbove, max);
  const warning = state !== 'normal' && !guarded;

  // The demo's clock: one step after another, round and round.
  useEffect(() => {
    if (!demoOn) {
      setStep(null);
      return;
    }
    let index = 0;
    let timer = 0;
    const run = () => {
      setStep(index);
      timer = window.setTimeout(() => {
        index = (index + 1) % SCRIPT.length;
        run();
      }, SCRIPT[index].ms);
    };
    run();
    return () => window.clearTimeout(timer);
  }, [demoOn]);

  // What the frame loop reads; kept in a ref so the loop never restarts for a new reading.
  const live = useRef({ target, angle: target, velocity: 0, time: 0, state, warning });
  live.current.target = target;
  live.current.state = state;
  live.current.warning = warning;

  const place = (angle: number) =>
    needle.current?.setAttribute('transform', `rotate(${angle.toFixed(2)} ${C} ${C})`);

  // A dip, a pop and a settle, when the reading crosses into a warning.
  const punch = useEffectEvent(() => {
    if (reduced || !dial.current?.animate) return;
    dial.current.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.94)', offset: 0.2 },
        { transform: 'scale(1.08)', offset: 0.48 },
        { transform: 'scale(0.99)', offset: 0.76 },
        { transform: 'scale(1)' },
      ],
      { duration: 460, easing: 'ease-out' },
    );
  });

  const previous = useRef(warning ? state : 'normal');
  useEffect(() => {
    const now = warning ? state : 'normal';
    if (now !== previous.current && now !== 'normal') punch();
    previous.current = now;
  }, [warning, state]);

  // Reduced motion, or off screen: the needle simply sits at its reading.
  useEffect(() => {
    if (visible && !reduced) return;
    const s = live.current;
    s.angle = target;
    s.velocity = 0;
    place(target);
  }, [visible, reduced, target]);

  // The frame loop: the needle's spring and tremor, and the heat haze's drift.
  useEffect(() => {
    if (!visible || reduced) return;
    let frame = 0;
    let last = 0;
    const tick = (now: number) => {
      const s = live.current;
      const dt = last ? Math.min(0.034, (now - last) / 1000) : 0;
      last = now;
      s.time += dt;
      // Semi-implicit Euler, in two half steps for a steady spring at any frame rate.
      for (let i = 0; i < 2; i += 1) {
        const h = dt / 2;
        s.velocity += (STIFFNESS * (s.target - s.angle) - DAMPING * s.velocity) * h;
        s.angle += s.velocity * h;
      }
      const k = s.time;
      const tremor = !s.warning
        ? 0.45 * Math.sin(k * 7.3) + 0.25 * Math.sin(k * 12.1 + 1)
        : s.state === 'cold'
          ? 1.6 * Math.sin(k * 61) + 0.9 * Math.sin(k * 37 + 2)
          : 1.4 * Math.sin(k * 9.4) + 0.6 * Math.sin(k * 23 + 1);
      place(Math.max(-138, Math.min(138, s.angle + tremor)));
      if (s.warning && s.state === 'hot')
        shift.current?.setAttribute('dy', ((k * 22) % 150).toFixed(2));
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [visible, reduced]);

  const describe = (c: number, inUnit: HeatUnit, isGuarded: boolean) => {
    const zone: HeatState = c < coldBelow ? 'cold' : c > hotAbove ? 'hot' : 'normal';
    const note = zone !== 'normal' && isGuarded ? ', resisted' : '';
    return `${show(c, inUnit)} °${inUnit}, ${WARNINGS[zone].toLowerCase()}${note}`;
  };

  const setTemperature = (next: number) => {
    take();
    setOwnT(next);
    setOwnUnit(u);
    setOwnShielded(guarded);
    setMessage(`${label}: ${describe(next, u, guarded)}`);
    onTemperatureChange?.(next);
  };

  const onRadioKey = (event: ReactKeyboardEvent, index: number) => {
    const move =
      event.key === 'ArrowRight' || event.key === 'ArrowDown'
        ? 1
        : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
          ? -1
          : 0;
    if (!move && event.key !== 'Home' && event.key !== 'End') return;
    event.preventDefault();
    const next =
      event.key === 'Home'
        ? 0
        : event.key === 'End'
          ? STATES.length - 1
          : (index + move + STATES.length) % STATES.length;
    setTemperature(HEAT_PRESETS[STATES[next]]);
    radios.current[next]?.focus();
  };

  // A controlled gauge says so when its reading crosses into another zone.
  const zone = warning ? state : 'normal';
  const announced = useRef(zone);
  useEffect(() => {
    if (zone === announced.current) return;
    announced.current = zone;
    if (temperature !== undefined) setMessage(`${label}: ${WARNINGS[zone].toLowerCase()}`);
  }, [zone, temperature, label]);

  const low = show(min, u);
  const high = show(max, u);

  const gauge = (
    <div
      ref={dial}
      className={styles.dial}
      data-part="dial"
      data-state={state}
      data-warning={warning ? state : 'none'}
      data-unit={u}
      role="meter"
      aria-label={label}
      aria-valuemin={low}
      aria-valuemax={high}
      aria-valuenow={Math.min(high, Math.max(low, show(t, u)))}
      aria-valuetext={describe(t, u, guarded)}
      style={{ '--size': `${size}px` } as CSSProperties}
    >
      <svg className={styles.art} viewBox="-25 -25 150 150" aria-hidden="true">
        <defs>
          <radialGradient id={`${ids}drop`} cx="50%" cy="50%" r="50%">
            <stop className={styles.dropStop} offset="0.78" />
            <stop className={styles.dropStop} offset="0.88" stopOpacity="0.45" />
            <stop className={styles.dropStop} offset="1" stopOpacity="0" />
          </radialGradient>
          <radialGradient id={`${ids}halo`} cx="50%" cy="50%" r="50%">
            <stop offset="0.6" stopColor="var(--warn)" stopOpacity="0" />
            <stop offset="0.7" stopColor="var(--warn)" stopOpacity="0.55" />
            <stop offset="0.82" stopColor="var(--warn)" stopOpacity="0.18" />
            <stop offset="1" stopColor="var(--warn)" stopOpacity="0" />
          </radialGradient>
          <filter id={`${ids}blur`} x="-30%" y="-30%" width="160%" height="160%">
            <feGaussianBlur stdDeviation="2.6" />
          </filter>
          <filter
            id={`${ids}haze`}
            filterUnits="userSpaceOnUse"
            primitiveUnits="userSpaceOnUse"
            x="-25"
            y="-25"
            width="150"
            height="150"
          >
            <feTurbulence
              type="fractalNoise"
              baseFrequency="0.05 0.11"
              numOctaves="2"
              seed="4"
              x="-25"
              y="-175"
              width="150"
              height="300"
              result="noise"
            />
            <feOffset ref={shift} in="noise" dy="0" result="drift" />
            <feDisplacementMap
              in="SourceGraphic"
              in2="drift"
              scale="7"
              xChannelSelector="R"
              yChannelSelector="G"
            />
          </filter>
        </defs>

        {/* The warnings behind the dial: a halo, and in the heat a rising haze. */}
        <g
          className={styles.behind}
          filter={warning && state === 'hot' && !reduced ? `url(#${ids}haze)` : undefined}
        >
          <circle
            className={styles.halo}
            data-part="halo"
            cx={C}
            cy={C}
            r={66}
            fill={`url(#${ids}halo)`}
          />
          <g className={styles.haze} data-part="haze">
            {WISPS.map((wisp) => (
              <path
                key={wisp.d}
                className={styles.wisp}
                d={wisp.d}
                style={{ animationDelay: `${wisp.delay}ms` } as CSSProperties}
              />
            ))}
          </g>
        </g>

        {/* What lets the dark disc read on any page: a soft shadow under it, a light rim round it. */}
        <circle
          className={styles.drop}
          data-part="drop"
          cx={C}
          cy={C + 2.5}
          r={58}
          fill={`url(#${ids}drop)`}
        />
        <circle className={styles.disc} data-part="disc" cx={C} cy={C} r={C} />
        <circle className={styles.rim} data-part="rim" cx={C} cy={C} r={C + 0.6} />

        <g className={styles.glows}>
          {GLOWS.map((glow) => (
            <path
              key={glow.zone}
              className={styles.glow}
              data-zone={glow.zone}
              d={glow.d}
              filter={`url(#${ids}blur)`}
            />
          ))}
        </g>
        <g className={styles.cells} data-part="arc">
          {CELLS.map((cell, i) => (
            <g key={i} data-zone={cell.zone}>
              <path className={styles.band} d={cell.band} />
              <path className={styles.track} d={cell.track} />
            </g>
          ))}
          {CUTS.map((d) => (
            <path key={d} className={styles.cut} d={d} />
          ))}
        </g>

        <g className={styles.frost} data-part="frost">
          {SHARDS.map((shard) => (
            <g key={shard.angle} transform={`rotate(${shard.angle} ${C} ${C})`}>
              <path
                className={styles.shard}
                d={shard.d}
                style={{ '--delay': `${shard.delay}ms` } as CSSProperties}
              />
            </g>
          ))}
          <circle className={styles.rime} cx={C} cy={C} r={48.6} />
        </g>

        <g className={styles.units} data-part="unit">
          <circle className={styles.degree} cx={42.25} cy={74.15} r={4} />
          <g className={styles.letter} data-letter="F">
            <path d="M50.85 69.75V89.25M49.25 71.35H61.5M49.25 79.2H59.6" />
          </g>
          <g className={styles.letter} data-letter="C">
            <path d="M58.4 73.45A4.5 8.15 0 1 0 58.4 85.55" />
          </g>
        </g>

        <g
          ref={needle}
          className={styles.needle}
          data-part="needle"
          transform={`rotate(${target.toFixed(2)} ${C} ${C})`}
        >
          <path d="M48.2 50.5V18H51.8V50.5Z" />
          <circle cx={C} cy={C} r={7.4} />
        </g>
      </svg>
    </div>
  );

  const rootProps = {
    ref: root,
    'data-color-scheme': colorScheme,
    'data-paused': visible ? undefined : '',
  };

  if (bare) {
    return (
      <div {...rootProps} className={`${styles.root} ${styles.bare} ${className}`}>
        {gauge}
      </div>
    );
  }

  return (
    <div
      {...rootProps}
      className={`${styles.root} ${styles.stage} ${className}`}
      data-state={state}
    >
      <div className={styles.hud} data-part="hud">
        <p className={styles.place} data-part="banner" aria-hidden="true">
          <span key={beat?.place ?? 'own'}>{beat?.place ?? WARNINGS[state]}</span>
        </p>
        <div className={styles.slot} style={{ '--slot': `${size}px` } as CSSProperties}>
          {gauge}
        </div>
        <div className={styles.controls} data-part="controls" onPointerDown={take}>
          <div className={styles.group} role="radiogroup" aria-label={`${label} zone`}>
            {STATES.map((name, i) => (
              <button
                key={name}
                ref={(node) => {
                  radios.current[i] = node;
                }}
                type="button"
                role="radio"
                aria-checked={state === name}
                tabIndex={state === name ? 0 : -1}
                className={styles.chip}
                data-state={name}
                onClick={() => setTemperature(HEAT_PRESETS[name])}
                onKeyDown={(event) => onRadioKey(event, i)}
              >
                <span className={styles.pip} aria-hidden="true" />
                {NAMES[name]}
              </button>
            ))}
          </div>
          <label className={`${styles.chip} ${styles.range}`}>
            <span className={styles.rangeLabel}>{label}</span>
            <input
              type="range"
              min={min}
              max={max}
              step={1}
              value={Math.round(t)}
              style={
                {
                  '--cold-at': `${(((coldBelow - min) / (max - min)) * 100).toFixed(1)}%`,
                  '--hot-at': `${(((hotAbove - min) / (max - min)) * 100).toFixed(1)}%`,
                } as CSSProperties
              }
              aria-valuetext={describe(Math.round(t), u, guarded)}
              onChange={(event) => setTemperature(Number(event.currentTarget.value))}
            />
            <output className={styles.readout}>
              {show(t, u)}°{u}
            </output>
          </label>
          <button
            type="button"
            className={styles.chip}
            aria-label={`Unit: ${u === 'F' ? 'Fahrenheit' : 'Celsius'}`}
            onClick={() => {
              take();
              const next = u === 'F' ? 'C' : 'F';
              setOwnT(t);
              setOwnShielded(guarded);
              setOwnUnit(next);
              setMessage(`Unit: ${next === 'F' ? 'Fahrenheit' : 'Celsius'}`);
              onUnitChange?.(next);
            }}
          >
            °{u}
          </button>
          <button
            type="button"
            className={styles.chip}
            aria-pressed={guarded}
            onClick={() => {
              take();
              setOwnT(t);
              setOwnUnit(u);
              setOwnShielded(!guarded);
              setMessage(guarded ? 'Gear off' : 'Gear on: warnings resisted');
              onShieldedChange?.(!guarded);
            }}
          >
            <span className={styles.gear} aria-hidden="true" />
            Gear
          </button>
        </div>
      </div>
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### heat-gauge/geometry.ts

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

/*
 * The heat gauge's drawing, worked out once: a 100-unit dial (radius 50) centred on (50, 50).
 * Angles are clock angles in degrees: 0 at the top, growing clockwise, so the dial's arc runs from
 * 225 (bottom left) over the top to 495 (= 135, bottom right): 270 degrees in all.
 */

export const C = 50;

/** The outer band and the thin inner track, inner and outer radii in units. */
export const BAND = { inner: 29.7, outer: 45.9 };
export const TRACK = { inner: 24.1, outer: 28.4 };
/** The dark cuts between cells run across both rings. */
export const CUT = { inner: 23, outer: 47.4, width: 1.8 };

export type HeatState = 'cold' | 'normal' | 'hot';

/** Each zone's span of the arc, unwrapped (420 is 60 again, 495 is 135). */
export const ZONES: Record<HeatState, [number, number]> = {
  cold: [225, 300],
  normal: [300, 420],
  hot: [420, 495],
};
/** Cells are 30 degrees, cut on the clock's own 30-degree marks: the two end cells are halves. */
const MARKS = [225, 240, 270, 300, 330, 360, 390, 420, 450, 480, 495];

const round = (n: number) => Math.round(n * 100) / 100;

export function point(r: number, angle: number): [number, number] {
  const a = (angle * Math.PI) / 180;
  return [round(C + r * Math.sin(a)), round(C - r * Math.cos(a))];
}

/** A ring's slice from a0 to a1 (clockwise), between radii r0 and r1. */
export function sector(r0: number, r1: number, a0: number, a1: number) {
  const [x0, y0] = point(r1, a0);
  const [x1, y1] = point(r1, a1);
  const [x2, y2] = point(r0, a1);
  const [x3, y3] = point(r0, a0);
  const large = a1 - a0 > 180 ? 1 : 0;
  return `M${x0} ${y0}A${r1} ${r1} 0 ${large} 1 ${x1} ${y1}L${x2} ${y2}A${r0} ${r0} 0 ${large} 0 ${x3} ${y3}Z`;
}

const zoneOf = (a0: number): HeatState => (a0 < 300 ? 'cold' : a0 < 420 ? 'normal' : 'hot');

/** The cells, each a band slice and a track slice, with its zone. */
export const CELLS = MARKS.slice(0, -1).map((a0, i) => ({
  zone: zoneOf(a0),
  band: sector(BAND.inner, BAND.outer, a0, MARKS[i + 1]),
  track: sector(TRACK.inner, TRACK.outer, a0, MARKS[i + 1]),
}));

/** The dark cuts between cells (not at the arc's two ends). */
export const CUTS = MARKS.slice(1, -1).map((a) => {
  const [x0, y0] = point(CUT.inner, a);
  const [x1, y1] = point(CUT.outer, a);
  return `M${x0} ${y0}L${x1} ${y1}`;
});

/** One slice per zone, both rings, for the blurred glow of a warning. */
export const GLOWS = (Object.keys(ZONES) as HeatState[]).map((zone) => ({
  zone,
  d: `${sector(BAND.inner, BAND.outer, ...ZONES[zone])}${sector(TRACK.inner, TRACK.outer, ...ZONES[zone])}`,
}));

/** The needle swings from -135 (coldest) to 135 (hottest); the zones change at -60 and 60. */
export const SWEEP = 135;
export const EDGE = 60;

/**
 * Where the needle points for a temperature: the cold, comfortable and hot spans of the scale each
 * fill their own zone of the arc, so the zone edges always fall on the cuts at -60 and 60.
 */
export function angleOf(t: number, min: number, coldBelow: number, hotAbove: number, max: number) {
  if (t <= coldBelow) {
    const span = coldBelow - min || 1;
    return Math.max(-SWEEP, -EDGE - ((coldBelow - t) / span) * (SWEEP - EDGE));
  }
  if (t >= hotAbove) {
    const span = max - hotAbove || 1;
    return Math.min(SWEEP, EDGE + ((t - hotAbove) / span) * (SWEEP - EDGE));
  }
  return -EDGE + ((t - coldBelow) / (hotAbove - coldBelow || 1)) * EDGE * 2;
}

/**
 * The frost: shards around the rim, each drawn pointing straight up from the top of the rim and
 * turned into place, so it can grow out of its own base. A seeded spread of angles and lengths.
 */
export const SHARDS = Array.from({ length: 18 }, (_, i) => {
  const wobble = Math.sin(i * 12.9898) * 43758.5453;
  // Rounded, so the server's and the browser's Math.sin agree on every digit drawn.
  const r = Math.round((wobble - Math.floor(wobble)) * 1000) / 1000;
  const angle = i * 20 + (r - 0.5) * 9;
  const length = round(4.5 + r * 6.5);
  const width = round(1.5 + (1 - r) * 1.3);
  const base = C - 46.5;
  const branch = length * 0.45;
  // A main spike, with a short side spike on alternate shards.
  const d =
    `M${round(C - width)} ${base + 1.5}L${C} ${round(base - length)}L${round(C + width)} ${base + 1.5}Z` +
    (i % 2
      ? `M${C + 0.4} ${round(base - branch * 0.5)}L${round(C + branch * 0.75)} ${round(base - branch * 1.15)}L${C + 0.9} ${round(base - branch * 0.05)}Z`
      : '');
  return { angle: round(angle), d, delay: Math.round(r * 260 + (i % 3) * 70) };
});

/** The heat haze: wisps rising from the top of the dial. */
export const WISPS = [
  { d: 'M36 14 C32 8 40 3 36 -3 S32 -12 36 -18', delay: 0 },
  { d: 'M50 6 C46 0 54 -5 50 -11 S46 -20 50 -26', delay: 520 },
  { d: 'M64 14 C68 8 60 3 64 -3 S68 -12 64 -18', delay: 1040 },
  { d: 'M24 26 C21 21 27 17 24 12 S21 5 24 0', delay: 780 },
  { d: 'M76 26 C79 21 73 17 76 12 S79 5 76 0', delay: 260 },
];
```

### heat-gauge/heat-gauge.module.css

```css
/* Heat gauge (heat-gauge.tsx): a dark thermometer dial alone on the page's own stage, with a HUD
   banner over it during the demo and small neutral chips under it to drive it.

   The dial is the same on both schemes, as a game HUD is: a near-black disc, a light cyan, deep
   navy and orange arc, a cyan needle. It carries its own edge so it reads on any page: a soft
   drop shadow under the disc and a thin light rim round it (strong on dark pages, where the disc
   would melt into the stage). The banner is a dark HUD plate on both schemes; the chips, the rim,
   the shadow and the frost's outline follow the scheme. Colours are the tokens below,
   light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits
   from the page, or the one its colorScheme prop sets (data-color-scheme).

   Sizes in the dial are in the drawing's units (a 100-unit dial; the kit's is 50 px); --size
   scales it all. The art box is half as large again as the dial, so warnings can spill past the
   rim. Everything is vector: the only blur is the warning glow, a layer under the sharp cells. */

.root {
  /* The dial, fixed on both schemes. */
  --disc: #141416;
  --cold: #6cf2fe;
  --mild: #02324b;
  --heat: #ff8102;
  --cold-track: #5fd3de;
  --mild-track: #062c3c;
  --heat-track: #e07a0c;
  --hand: #6cf0fe;
  --frost: #e9fcff;
  /* The dial's own edge: its rim, its shadow, and the frost shards' outline. */
  --rim: rgb(255 255 255 / 0.06);
  --drop: rgb(20 24 32 / 0.34);
  --shard-edge: #1f98aa;
  /* The HUD banner: a dark plate on both schemes. */
  --plate: rgb(18 18 20 / 0.88);
  --plate-ink: #f3efe4;
  --plate-edge: rgb(255 255 255 / 0.1);
  /* The chips: neutral lab controls. */
  --chip: #fff;
  --chip-hover: #f1f1f3;
  --chip-on: rgb(0 0 0 / 0.06);
  --chip-ink: #17191d;
  --chip-mute: rgb(23 25 29 / 0.62);
  --chip-edge: rgb(0 0 0 / 0.1);
  --chip-shadow: rgb(0 0 0 / 0.05);
  --mild-chip: #1d5a80;
  --cold-mark: #1aa6b8;
  --heat-mark: #e57300;
  --focus: #387bff;
  position: relative;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --rim: light-dark(rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.2));
    --drop: light-dark(rgb(20 24 32 / 0.34), rgb(0 0 0 / 0.7));
    --shard-edge: light-dark(#1f98aa, #6cf2fe);
    --chip: light-dark(#fff, #26262a);
    --chip-hover: light-dark(#f1f1f3, #313136);
    --chip-on: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.1));
    --chip-ink: light-dark(#17191d, #ececef);
    --chip-mute: light-dark(rgb(23 25 29 / 0.62), rgb(236 236 239 / 0.62));
    --chip-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.08));
    --chip-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --mild-chip: light-dark(#1d5a80, #3a7ca8);
    --cold-mark: light-dark(#1aa6b8, #6cf2fe);
    --heat-mark: light-dark(#e57300, #ff8102);
    --focus: light-dark(#387bff, #9ff6ff);
  }
}
.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 layout: the banner, the dial in the middle, the chips under it. No backdrop: the
   page's own stage shows through. ——— */
.stage {
  container-type: inline-size;
  width: 100%;
  min-height: 400px;
  display: grid;
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
}
.stage .hud {
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 12px;
  padding: 8px 0;
}
/* Where the player is, or the zone: a HUD banner on a dark plate that slides in when it changes. */
.stage .place {
  margin: 0;
  padding: 6px 14px;
  border-radius: 999px;
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    0 2px 8px var(--chip-shadow);
  color: var(--plate-ink);
  font-size: 13px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: 0.02em;
}
.stage .place span {
  display: block;
  animation: place-in 360ms cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
}
@keyframes place-in {
  from {
    opacity: 0;
    translate: 0 -6px;
  }
}
.stage .slot {
  display: grid;
  place-items: center;
  /* Room for the frost and the haze that spill past the rim. */
  padding: calc(var(--slot, 180px) * 0.2);
}

/* ——— The dial ——— */
.dial {
  --size: 120px;
  --warn: var(--cold);
  position: relative;
  display: block;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
}
.dial[data-state='hot'] {
  --warn: var(--heat);
}
.dial .art {
  position: absolute;
  inset: -25%;
  display: block;
  width: 150%;
  height: 150%;
  overflow: visible;
  pointer-events: none;
}
.dial .disc {
  fill: var(--disc);
}
.dial .rim {
  fill: none;
  stroke: var(--rim);
  stroke-width: 1.2px;
}
.dial .dropStop {
  stop-color: var(--drop);
}
.dial .cells [data-zone='cold'] .band {
  fill: var(--cold);
}
.dial .cells [data-zone='normal'] .band {
  fill: var(--mild);
}
.dial .cells [data-zone='hot'] .band {
  fill: var(--heat);
}
.dial .cells [data-zone='cold'] .track {
  fill: var(--cold-track);
}
.dial .cells [data-zone='normal'] .track {
  fill: var(--mild-track);
}
.dial .cells [data-zone='hot'] .track {
  fill: var(--heat-track);
}
.dial .cut {
  fill: none;
  stroke: var(--disc);
  stroke-width: 1.8px;
}

/* The needle and the unit, in the hand's cyan. */
.dial .needle {
  fill: var(--hand);
}
.dial .units {
  fill: none;
  stroke: var(--hand);
}
.dial .degree {
  stroke-width: 2.4px;
}
.dial .letter {
  stroke-width: 3.2px;
  transform-box: fill-box;
  transform-origin: 50% 50%;
  transition:
    opacity 140ms ease-out,
    scale 220ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
.dial[data-unit='F'] .letter[data-letter='C'],
.dial[data-unit='C'] .letter[data-letter='F'] {
  opacity: 0;
  scale: 1 0;
  transition:
    opacity 120ms ease-in,
    scale 120ms ease-in;
}

/* ——— Warnings ——— */
/* The arc's glow: a blurred copy of the warning zone, breathing. */
.dial .glow {
  opacity: 0;
  transition: opacity 320ms ease-out;
}
.dial .glow[data-zone='cold'] {
  fill: var(--cold);
}
.dial .glow[data-zone='hot'] {
  fill: var(--heat);
}
.dial .glow[data-zone='normal'] {
  display: none;
}
.dial[data-warning='cold'] .glow[data-zone='cold'],
.dial[data-warning='hot'] .glow[data-zone='hot'] {
  opacity: 1;
  animation: breathe 1100ms ease-in-out infinite alternate;
}
@keyframes breathe {
  from {
    opacity: 0.35;
  }
  to {
    opacity: 1;
  }
}
/* The halo round the rim. */
.dial .halo {
  opacity: 0;
  transition: opacity 360ms ease-out;
}
.dial[data-warning='cold'] .halo,
.dial[data-warning='hot'] .halo {
  opacity: 0.9;
  animation: breathe 1100ms ease-in-out infinite alternate;
}

/* Frost: shards grow out of the rim one after another, and melt back together. */
.dial .shard {
  fill: var(--frost);
  stroke: var(--shard-edge);
  stroke-width: 0.6px;
  stroke-linejoin: round;
  transform-box: fill-box;
  transform-origin: 50% 100%;
  scale: 0.2 0;
  opacity: 0;
  transition:
    scale 220ms ease-in,
    opacity 220ms ease-in;
}
.dial[data-warning='cold'] .shard {
  scale: 1;
  opacity: 0.95;
  transition:
    scale 520ms cubic-bezier(0.2, 1.4, 0.4, 1),
    opacity 200ms ease-out;
  transition-delay: var(--delay, 0ms);
}
.dial .rime {
  fill: none;
  stroke: var(--frost);
  stroke-width: 1.4px;
  stroke-dasharray: 1.2 2.6;
  opacity: 0;
  transition: opacity 400ms ease-out;
}
.dial[data-warning='cold'] .rime {
  opacity: 0.75;
}

/* Heat haze: wisps rising and fading, bent by the turbulence filter on their group. */
.dial .wisp {
  fill: none;
  stroke: var(--heat);
  stroke-width: 2.6px;
  stroke-linecap: round;
  opacity: 0;
}
.dial[data-warning='hot'] .wisp {
  animation: rise 1560ms ease-out infinite;
}
@keyframes rise {
  0% {
    opacity: 0;
    translate: 0 8px;
  }
  30% {
    opacity: 0.75;
  }
  100% {
    opacity: 0;
    translate: 0 -10px;
  }
}

/* Paused off screen or in a hidden tab (the loop is, too). */
.root[data-paused] .dial * {
  animation-play-state: paused;
}

/* ——— The chips ——— */
.stage .controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  max-width: 100%;
}
.stage .group {
  display: flex;
  gap: 4px;
  padding: 4px;
  border-radius: 999px;
  background: var(--chip);
  box-shadow:
    inset 0 0 0 1px var(--chip-edge),
    0 1px 2px var(--chip-shadow);
}
.stage .chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow:
    inset 0 0 0 1px var(--chip-edge),
    0 1px 2px var(--chip-shadow);
  color: var(--chip-mute);
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.01em;
  cursor: pointer;
  transition:
    background-color 160ms ease-out,
    color 160ms ease-out,
    box-shadow 160ms ease-out;
}
.stage .group .chip {
  background: transparent;
  box-shadow: none;
}
.stage .chip:hover {
  color: var(--chip-ink);
}
.stage .group .chip:hover,
.stage .controls > button.chip:hover {
  background: var(--chip-hover);
}
.stage .chip:focus-visible,
.stage .range:has(input:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.stage button.chip:active {
  scale: 0.96;
}
.stage .chip[aria-checked='true'],
.stage .chip[aria-pressed='true'] {
  color: var(--chip-ink);
}
.stage .group .chip[aria-checked='true'] {
  background: var(--chip-on);
}
.stage .group .chip[data-state='cold'][aria-checked='true'] {
  box-shadow: inset 0 0 0 1px var(--cold-mark);
}
.stage .group .chip[data-state='normal'][aria-checked='true'] {
  box-shadow: inset 0 0 0 1px var(--mild-chip);
}
.stage .group .chip[data-state='hot'][aria-checked='true'] {
  box-shadow: inset 0 0 0 1px var(--heat-mark);
}
/* A swatch per zone, in the arc's colours. */
.stage .pip {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--cold);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.18);
  opacity: 0.55;
  transition:
    opacity 160ms ease-out,
    box-shadow 160ms ease-out;
}
.stage .chip[data-state='normal'] .pip {
  background: var(--mild-chip);
}
.stage .chip[data-state='hot'] .pip {
  background: var(--heat);
}
.stage .chip[aria-checked='true'] .pip {
  opacity: 1;
  box-shadow:
    inset 0 0 0 1px rgb(0 0 0 / 0.18),
    0 0 6px 1px currentColor;
}
.stage .chip[data-state='cold'][aria-checked='true'] .pip {
  color: var(--cold);
}
.stage .chip[data-state='hot'][aria-checked='true'] .pip {
  color: var(--heat);
}
.stage .chip[data-state='normal'][aria-checked='true'] .pip {
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.18);
}
/* The gear toggle's light: off, or lit. */
.stage .gear {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1.5px currentColor;
  transition:
    background-color 160ms ease-out,
    box-shadow 160ms ease-out;
}
.stage .chip[aria-pressed='true'] .gear {
  background: var(--hand);
  box-shadow:
    inset 0 0 0 1px var(--shard-edge),
    0 0 6px 1px var(--hand);
}

/* The temperature slider, inside a chip. */
.stage .range {
  cursor: default;
  padding-right: 12px;
}
.stage .rangeLabel {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.stage .range input {
  width: 116px;
  height: 20px;
  margin: 0;
  background: transparent;
  cursor: pointer;
  appearance: none;
}
.stage .range input:focus-visible {
  outline: none;
}
.stage .range input::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background: linear-gradient(
    90deg,
    var(--cold) 0 var(--cold-at, 33%),
    var(--mild-chip) var(--cold-at, 33%) var(--hot-at, 69%),
    var(--heat) var(--hot-at, 69%)
  );
}
.stage .range input::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: linear-gradient(
    90deg,
    var(--cold) 0 var(--cold-at, 33%),
    var(--mild-chip) var(--cold-at, 33%) var(--hot-at, 69%),
    var(--heat) var(--hot-at, 69%)
  );
}
.stage .range input::-webkit-slider-thumb {
  width: 14px;
  height: 14px;
  margin-top: -5px;
  border: 2px solid var(--disc);
  border-radius: 50%;
  background: var(--hand);
  appearance: none;
}
.stage .range input::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: 2px solid var(--disc);
  border-radius: 50%;
  background: var(--hand);
}
.stage .readout {
  min-width: 5ch;
  color: var(--chip-ink);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

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

.bare {
  display: inline-block;
  line-height: 0;
}

@container (max-width: 480px) {
  .stage .chip {
    padding: 0 11px;
  }
  .stage .range input {
    width: 96px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dial .letter,
  .dial .glow,
  .dial .halo,
  .dial .shard,
  .dial[data-warning='cold'] .shard,
  .dial .rime,
  .stage .chip,
  .stage .pip,
  .stage .gear {
    transition: none;
  }
  .stage .place span,
  .dial[data-warning='cold'] .glow[data-zone='cold'],
  .dial[data-warning='hot'] .glow[data-zone='hot'],
  .dial[data-warning='cold'] .halo,
  .dial[data-warning='hot'] .halo {
    animation: none;
  }
  /* The haze, still: two wisps half risen. */
  .dial[data-warning='hot'] .wisp {
    animation: none;
    opacity: 0.45;
  }
  .stage button.chip:active {
    scale: none;
  }
}
```

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

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

'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` alone (on screen, tab shown) is also
 * returned, so a live drawing can pause offscreen whoever is driving it.
 */
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 };
}
```