ArcReveal
A donut whose slices sweep in while the centre total counts up on the same clock — the number is written from inside the draw loop, so the two cannot desync.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/arc-reveal.jsonChart · canvas + DOMcanvas
100kinstalls
- Atmosphere38%
- Demo surfaces26%
- Primitives21%
- Data & state15%
68%capacity
- Used68%
- Free32%
"use client";
import * as React from "react";
import {
cx,
DEFAULT_LANTERN,
DEFAULT_WISP,
hsl,
mixHsl,
useCanvasScene,
useRevealClock,
useTokenColors,
useWispGain,
type Hsl,
} from "@pouriahlabs/wisp-ui";
/**
* ArcReveal — a donut whose segments sweep in while the centre total counts up
* on the same clock.
*
* The composition is the component. A ring that draws on and a number that
* counts are each a five-line effect; the reason breakdown charts usually feel
* assembled rather than designed is that the two run on separate timers and
* finish a quarter of a second apart. Here there is one clock: the total is
* written from inside the draw loop, so the last degree of arc and the last
* digit land on the same frame. They cannot desync, because there is nothing to
* keep in sync.
*
* The ring is made of the seam. Segments without an explicit token are
* interpolated between `--wisp` and `--lantern`, so a six-slice breakdown
* introduces no new colours and still reads as six distinct slices. Name a
* token on a segment when a slice is semantic rather than merely another slice.
*
* The canvas is decoration. The legend is the chart: it carries every label and
* value as real text, so the breakdown survives a screen reader, a text-only
* render and a failed paint. `legend={false}` therefore hides it from *sight*
* only — it stays in the document, clipped, the same way `.wisp-chip-read`
* carries the direction word a ▲ merely looks like it says. A ring that is a
* bare canvas to a screen reader is not a chart; it is a decoration that used
* to be one, and a layout preference should not be able to make that trade.
*/
export interface ArcSegment {
label: string;
value: number;
/** Token for this slice. Omit to take a colour from the seam. */
token?: string;
}
export interface ArcRevealProps extends React.HTMLAttributes<HTMLDivElement> {
data: readonly ArcSegment[];
/** 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 donut shouldn't reset every few seconds. Set it for a demo.
*/
loop?: number;
/** Space between slices, degrees. */
gap?: number;
/** Where the sweep starts, degrees clockwise from twelve o'clock. */
startAngle?: number;
/** Format the centre total. Overrides `locale`. */
format?: (total: number) => string;
/** Locale for the default total's grouping separators. Pass `false` for none. */
locale?: string | false;
/** Small line under the centre total. */
caption?: string;
/**
* Show the labelled legend. `false` clips it rather than dropping it — it is
* the chart's accessible content and does not come off.
*/
legend?: boolean;
}
const SEAM: [Hsl, Hsl] = [DEFAULT_WISP, DEFAULT_LANTERN];
export function ArcReveal({
data,
size = 132,
thickness = 12,
duration = 1300,
progress,
loop = 0,
gap = 3,
startAngle = -90,
locale = "en-US",
format = (total) =>
locale === false ? String(Math.round(total)) : Math.round(total).toLocaleString(locale),
caption,
legend = true,
className,
style,
...props
}: ArcRevealProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const totalRef = React.useRef<HTMLSpanElement>(null);
const [wisp, lantern] = useTokenColors(["--wisp", "--lantern"], 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 });
// One hook for every named slice, whatever the data length — the array of
// names is what varies, not the number of hooks.
const named = React.useMemo(
() => Array.from(new Set(data.map((d) => d.token).filter(Boolean) as string[])),
[data],
);
const namedFallbacks = React.useMemo(() => named.map(() => SEAM[0]), [named]);
const namedColors = useTokenColors(named, namedFallbacks);
const total = data.reduce((sum, d) => sum + d.value, 0);
const colorAt = React.useCallback(
(i: number): Hsl => {
const token = data[i]?.token;
if (token) {
const found = named.indexOf(token);
if (found >= 0 && namedColors[found]) return namedColors[found]!;
}
const steps = Math.max(1, data.length - 1);
return mixHsl(wisp ?? SEAM[0], lantern ?? SEAM[1], i / steps);
},
[data, named, namedColors, wisp, lantern],
);
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 supposed to be describing.
if (totalRef.current) totalRef.current.textContent = format(total * t);
const cx0 = width / 2;
const cy0 = height / 2;
const radius = Math.min(width, height) / 2 - thickness / 2 - 1;
if (radius <= 0 || total <= 0) return;
const start = (startAngle * Math.PI) / 180;
const gapRad = (gap * Math.PI) / 180;
const sweep = Math.PI * 2 * t;
// The track: without it a part-drawn ring reads as a ring that is missing
// a piece rather than one that is still arriving.
ctx.strokeStyle = hsl(wisp ?? SEAM[0], (wisp ?? SEAM[0])[2], 0.11 * gain);
ctx.lineWidth = thickness;
ctx.beginPath();
ctx.arc(cx0, cy0, radius, 0, Math.PI * 2);
ctx.stroke();
let angle = start;
for (let i = 0; i < data.length; i++) {
const share = (data[i]!.value / total) * Math.PI * 2;
const from = angle;
const to = angle + share;
angle = to;
const cutoff = Math.min(to, start + sweep);
if (cutoff <= from) break;
const closed = cutoff >= to - 1e-6;
const a0 = from + gapRad / 2;
const a1 = closed ? cutoff - gapRad / 2 : cutoff;
if (a1 <= a0) continue;
const color = colorAt(i);
ctx.strokeStyle = hsl(color, color[2], 0.95);
ctx.lineWidth = thickness;
ctx.lineCap = "butt";
ctx.beginPath();
ctx.arc(cx0, cy0, radius, a0, a1);
ctx.stroke();
}
return done;
},
});
return (
<div className={cx("wisp-arc", className)} style={style} {...props}>
<div className="wisp-arc-ring" style={{ width: size, height: size }}>
<canvas ref={ref} aria-hidden className="block h-full w-full" />
<span className="wisp-arc-center">
{/* Rendered settled, then overwritten every frame. Without JavaScript,
or before the first frame, this is already the true total. */}
<span ref={totalRef} className="wisp-arc-total tabular-nums">
{format(total)}
</span>
{caption ? <span className="wisp-arc-caption">{caption}</span> : null}
</span>
</div>
<ul className={cx("wisp-arc-legend", !legend && "is-read")}>
{data.map((slice, i) => {
const color = colorAt(i);
return (
<li key={slice.label} className="wisp-arc-item">
<i aria-hidden className="wisp-arc-swatch" style={{ background: hsl(color) }} />
<span className="wisp-arc-label">{slice.label}</span>
<span className="wisp-arc-value tabular-nums">
{total > 0 ? `${Math.round((slice.value / total) * 100)}%` : "—"}
</span>
</li>
);
})}
</ul>
</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