Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Heat gauge

New

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

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

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

When to use

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

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

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

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

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

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

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

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

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

  • 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.
PropTypeDefaultDescription
temperaturenumber—The temperature where the player stands, in °C. Set it to control the gauge; the demo is then off.
defaultTemperaturenumber18The 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''F'The unit an uncontrolled gauge starts with.
onUnitChange(unit: 'F' | 'C') => void—Called when the reader switches the unit.
shieldedboolean—The player’s gear resists the current zone: the needle still reads, no warning plays. Set it to control it.
defaultShieldedbooleanfalseWhether an uncontrolled gauge starts shielded.
onShieldedChange(shielded: boolean) => void—Called when the reader toggles the gear.
minnumber-30The coldest reading, in °C: the needle’s stop at the bottom left.
coldBelownumber0Below this (°C) it is too cold: the needle is in the cyan cells.
hotAbovenumber32Above this (°C) it is too hot: the needle is in the orange cells.
maxnumber60The hottest reading, in °C: the needle’s stop at the bottom right.
sizenumber180The dial’s width in px; the kit draws it at 50.
barebooleanfalseJust the dial: no banner and no controls, to place in a HUD of your own.
labelstring'Temperature'The gauge’s accessible name.
autoplaybooleantrueWhile 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.
classNamestring—Added to the root element.

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

heat-gauge.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. *
geometry.ts
// 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],};
heat-gauge.module.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;
use-autoplay.ts
// 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);
  • 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.