Gauge · MeterBar
Single-value progress: an open ring whose arc and centre count-up share one reveal clock, and a tone-coloured semantic meter bar that holds a completed still frame when motion is off.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/gauge.jsonChart · canvas + DOMcanvas
Uptime82
Disk64
Error budget93
"use client";
import * as React from "react";
import {
cx,
DEFAULT_LANTERN,
DEFAULT_WISP,
hsl,
mixHsl,
useCanvasScene,
useReducedMotion,
useRevealClock,
useTokenColors,
useWispGain,
type Hsl,
} from "@pouriahlabs/wisp-ui";
/**
* Gauge — a single value, drawn as an open ring that sweeps in while the centre
* number counts up on the same clock.
*
* This is ArcReveal's single-value sibling. ArcReveal answers "what is this made
* of"; a gauge answers "how full is it" — one figure against a ceiling, the
* shape a storage quota, a rate limit or a completion percentage actually wants.
* The ring is open (270° by default) because a value against a maximum has a
* floor and a ceiling, and a closed donut hides where those are: the two ends of
* the arc *are* empty and full, and the gap at the bottom is what makes that
* legible at a glance.
*
* There is one clock. The arc and the centre number share the reveal exactly as
* ArcReveal's ring and total do — the number is written straight to the DOM from
* inside the draw loop, not held in React state, so the last degree of sweep and
* the last digit land on the same frame instead of a re-render apart. A count-up
* that finishes a frame after the arc it describes is the tell of two timers; here
* there is only one, so there is nothing to desync.
*
* The fill is the seam. Rather than one flat accent it is painted across the arc
* from `--wisp` to `--lantern`, so the value carries a direction — emptier reads
* cooler, fuller reads warmer — without introducing a colour the palette doesn't
* already own. Name semantic tokens on `fromToken`/`toToken` when a gauge is a
* health bar rather than a neutral quota.
*
* The canvas is decoration. The ring wrapper is `role="img"` with an
* `aria-label` carrying the settled value and maximum, so the gauge survives a
* screen reader, a text-only render and a failed paint — a bare canvas is not a
* gauge, it is a decoration that used to be one.
*/
export interface GaugeProps extends React.HTMLAttributes<HTMLDivElement> {
/** The value to display. */
value: number;
/** The full-scale ceiling `value` is measured against. */
max?: number;
/** Ring diameter, CSS pixels. */
size?: number;
/** Ring thickness, CSS pixels. */
thickness?: number;
/** Sweep duration, ms. Ignored when `progress` is supplied. */
duration?: number;
/** Externally driven reveal, `0`–`1`. Omit to self-animate. */
progress?: number;
/**
* Pause before replaying, ms. `0` (the default) sweeps once and holds — a
* dashboard gauge shouldn't reset every few seconds. Set it for a demo.
*/
loop?: number;
/** Where the arc starts, degrees clockwise from twelve o'clock. Defaults to
* the bottom-left, so a 270° sweep leaves the gap centred at six o'clock. */
startAngle?: number;
/** How far the full track spans, degrees. 270 is the open-ring default. */
sweepAngle?: number;
/** Token for the empty end of the fill. */
fromToken?: string;
/** Token for the full end of the fill — the seam runs `fromToken`→`toToken`. */
toToken?: string;
/** Format the centre number. Overrides `locale`. */
format?: (value: number) => string;
/** Locale for the default number's grouping separators. Pass `false` for none. */
locale?: string | false;
/** Small line under the centre number. */
caption?: string;
}
const SEAM: [Hsl, Hsl] = [DEFAULT_WISP, DEFAULT_LANTERN];
const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);
export function Gauge({
value,
max = 100,
size = 132,
thickness = 12,
duration = 1300,
progress,
loop = 0,
startAngle = 135,
sweepAngle = 270,
fromToken = "--wisp",
toToken = "--lantern",
locale = "en-US",
format = (n) =>
locale === false ? String(Math.round(n)) : Math.round(n).toLocaleString(locale),
caption,
className,
style,
...props
}: GaugeProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const numRef = React.useRef<HTMLSpanElement>(null);
const [from, to] = useTokenColors([fromToken, toToken], SEAM);
const gain = useWispGain();
// The public props are ms; the canvas ticks the reveal clock in seconds.
const { tick, reset } = useRevealClock({ duration: duration / 1000, loop: loop / 1000, progress });
// The share of the track the value fills. Kept out of the draw loop's hot
// path — it changes with props, not with the frame.
const frac = max > 0 ? clamp01(value / max) : 0;
useCanvasScene(ref, {
setup: () => reset(),
draw: ({ ctx, width, height, dt, still }) => {
ctx.clearRect(0, 0, width, height);
if (width < 8 || height < 8) return;
const t = tick(dt, still);
// Driven reveals repaint on the parent's render; a self-animating one
// holds once finished and not looping — either way the loop can stop.
const done = progress !== undefined || (loop === 0 && t >= 1);
// The one clock, written straight to the DOM rather than through state: a
// re-render per frame to move one text node is the wrong trade, and going
// through React is also how the number ends up a frame behind the arc it
// is describing. The value counts, not the fraction — a quota reads "72",
// not "0.72".
if (numRef.current) numRef.current.textContent = format(value * t);
const cx0 = width / 2;
const cy0 = height / 2;
const radius = Math.min(width, height) / 2 - thickness / 2 - 1;
if (radius <= 0) return done;
const start = (startAngle * Math.PI) / 180;
const sweep = (sweepAngle * Math.PI) / 180;
// The track: without it a part-filled gauge reads as a short arc floating
// in space rather than a value sitting low against its ceiling.
const base = from ?? SEAM[0];
ctx.strokeStyle = hsl(base, base[2], 0.12 * gain);
ctx.lineWidth = thickness;
ctx.lineCap = "round";
ctx.beginPath();
ctx.arc(cx0, cy0, radius, start, start + sweep);
ctx.stroke();
// The fill, painted across the arc as the seam. Subdividing lets the
// gradient run `fromToken`→`toToken` along the *whole* sweep, so a
// segment's colour depends on where it sits on the track, not on how far
// the reveal has come — the hue at a given angle is stable as it fills in.
const filled = sweep * frac * t;
if (filled > 1e-4) {
const steps = Math.max(2, Math.ceil(filled / (Math.PI / 90)));
ctx.lineCap = "round";
for (let i = 0; i < steps; i++) {
const a0 = start + (filled * i) / steps;
// A hair of overlap so butt-joined segments leave no gap on the seam.
const a1 = start + (filled * (i + 1)) / steps + 1e-3;
const pos = (a0 - start + a1 - start) / 2 / sweep;
const color = mixHsl(from ?? SEAM[0], to ?? SEAM[1], clamp01(pos));
ctx.strokeStyle = hsl(color, color[2], 0.95);
ctx.beginPath();
ctx.arc(cx0, cy0, radius, a0, a1);
ctx.stroke();
}
}
return done;
},
});
return (
<div className={cx("wisp-gauge", className)} style={style} {...props}>
<div
className="wisp-gauge-ring"
style={{ position: "relative", flex: "none", width: size, height: size }}
// The canvas is aria-hidden; this label is the gauge as far as a screen
// reader is concerned. It carries the settled value, not the animating
// one — a count-up announced digit by digit is noise, not information.
role="img"
aria-label={`${format(value)} of ${format(max)}`}
>
<canvas ref={ref} aria-hidden className="block h-full w-full" />
<span
className="wisp-gauge-center"
aria-hidden
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
pointerEvents: "none",
}}
>
{/* Rendered settled, then overwritten every frame. Without JavaScript,
or before the first frame, this is already the true value. */}
<span
ref={numRef}
className="wisp-gauge-value tabular-nums"
style={{ fontSize: "1.25rem", lineHeight: 1.05, letterSpacing: "-0.025em" }}
>
{format(value)}
</span>
{caption ? (
<span
className="wisp-gauge-caption"
style={{ fontSize: "0.6875rem", letterSpacing: "0.14em", textTransform: "uppercase", opacity: 0.7 }}
>
{caption}
</span>
) : null}
</span>
</div>
</div>
);
}
/**
* MeterBar — a linear meter whose fill is coloured by sentiment.
*
* The horizontal counterpart to Gauge, and the one to reach for when the number
* is answering "is this healthy" rather than "how full is it". Where Gauge is a
* neutral quota painted along the seam, a MeterBar's colour is semantic: `tone`
* maps to `--wisp-up` (a good level), `--wisp-down` (a critical one) or a warning
* token, kept deliberately separate from the brand accent for the same reason
* DeltaChip is — a bar that turns the accent colour when a value goes critical
* has told the reader nothing, because the accent is also just "on brand".
*
* No canvas. It is a track and a fill div, so it renders correctly on the server
* and needs no measurement — the value is known, not observed. The fill
* transitions its width, and that transition is switched off under reduced
* motion via `useReducedMotion` rather than left to a media query, so the
* still-frame contract is met in JavaScript: a reader who asked to hold still
* sees the completed bar at the correct width immediately, never a widening one.
*
* The bar is `role="progressbar"` with the real `aria-valuenow`/`min`/`max`, so
* the level is announced as a value, not inferred from a coloured rectangle.
*/
export type MeterTone = "brand" | "success" | "critical" | "warning";
export interface MeterBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** The value to display. */
value: number;
/** The full-scale ceiling `value` is measured against. */
max?: number;
/** Which semantic token colours the fill. `brand` is the neutral default. */
tone?: MeterTone;
/** Label shown above the bar, and the bar's accessible name. */
label?: string;
/** Show the numeric value beside the label. */
showValue?: boolean;
}
// Semantic tokens, each with the channel fallback used before CSS resolves — a
// var() reference so the fill re-colours on a theme swap for free, without the
// component re-reading anything.
const TONE_TOKEN: Record<MeterTone, { token: string; fallback: string }> = {
brand: { token: "--wisp", fallback: "162 72% 52%" },
success: { token: "--wisp-up", fallback: "162 72% 52%" },
critical: { token: "--wisp-down", fallback: "356 64% 60%" },
warning: { token: "--wisp-warn", fallback: "42 88% 62%" },
};
export function MeterBar({
value,
max = 100,
tone = "brand",
label,
showValue = false,
className,
style,
...props
}: MeterBarProps) {
const still = useReducedMotion();
const frac = max > 0 ? clamp01(value / max) : 0;
// Start empty and fill in an effect so the width transition actually plays on
// mount; a bar rendered at its final width has nothing to animate from.
const [shown, setShown] = React.useState(0);
React.useEffect(() => setShown(frac), [frac]);
// The still frame is the completed bar. Reading `frac` directly under reduced
// motion — rather than the animated `shown` — means it is correct on the first
// painted frame, independent of when the fill effect runs.
const width = `${(still ? frac : shown) * 100}%`;
const { token, fallback } = TONE_TOKEN[tone];
const color = `hsl(var(${token}, ${fallback}))`;
return (
<div className={cx("wisp-meter", className)} style={style} {...props}>
{label || showValue ? (
<div
className="wisp-meter-head"
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8, marginBottom: 6 }}
>
{label ? <span className="wisp-meter-label">{label}</span> : <span />}
{showValue ? (
<span className="wisp-meter-num tabular-nums" style={{ fontVariantNumeric: "tabular-nums", opacity: 0.75 }}>
{value}
</span>
) : null}
</div>
) : null}
<div
className="wisp-meter-track"
role="progressbar"
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={Math.round(max)}
aria-label={label}
style={{
position: "relative",
height: 8,
borderRadius: 999,
overflow: "hidden",
background: `hsl(var(${token}, ${fallback}) / 0.14)`,
}}
>
<div
className="wisp-meter-fill"
style={{
height: "100%",
width,
borderRadius: 999,
background: color,
// Switched off, not just shortened, under reduced motion — the still
// frame is a completed bar, not a fast one.
transition: still ? "none" : "width 700ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
/>
</div>
</div>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React