A semicircular gauge with threshold zones and a needle that sweeps to the current ratio and retargets on input.
npx shadcn@latest add @paragon/affordability-gauge"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface AffordabilityZone {
/** Upper bound of this zone as a fraction of the arc (0–1), ascending. */
upTo: number;
color: string;
label: string;
}
export interface AffordabilityGaugeProps extends React.ComponentProps<"div"> {
/** Current ratio to plot (e.g. debt-to-income), 0–1. Clamped. */
value: number;
size?: number;
/** Ascending threshold zones painted along the arc. */
zones?: AffordabilityZone[];
/** Caption under the readout. */
caption?: string;
/** Formats the center readout. Defaults to a percentage. */
formatValue?: (value: number) => string;
/** Renders the needle at target immediately, no sweep. */
static?: boolean;
}
const DEFAULT_ZONES: AffordabilityZone[] = [
{ upTo: 0.36, color: "var(--color-success)", label: "Comfortable" },
{ upTo: 0.43, color: "var(--color-warning)", label: "Stretched" },
{ upTo: 1, color: "var(--color-destructive)", label: "Overextended" },
];
/**
* A semicircular (180°) affordability gauge. The value arc sweeps left→right
* across the top; zone segments are parametric arc paths (polar → cartesian)
* with butt caps, so every boundary reads as a crisp break in the band. Since
* no segment ever exceeds 180°, the large-arc flag is always 0 — the band
* stays a clean semicircle and never wraps back through the readout. Boundary
* percentages are labeled on the rim (measure-and-skip so adjacent thresholds
* never collide) and a tapered needle sweeps to the current ratio on an
* interruptible CSS rotation. The needle lives entirely in the upper half; the
* readout sits below the pivot so the two never touch. Everything is contained
* within the padded viewBox.
*
* Interactive: hover, tap, or keyboard-focus a zone band to crossfade the
* readout to its range and label. Reduced motion places the needle without
* sweeping.
*/
export function AffordabilityGauge({
value,
size = 240,
zones = DEFAULT_ZONES,
caption,
formatValue = (v) => `${Math.round(v * 100)}%`,
static: isStatic = false,
className,
...props
}: AffordabilityGaugeProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const [active, setActive] = React.useState<number | null>(null);
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const clamped = Math.min(Math.max(value, 0), 1);
const activeZoneIndex = zones.findIndex((z) => clamped <= z.upTo);
const resolvedActiveIndex =
activeZoneIndex === -1 ? zones.length - 1 : activeZoneIndex;
const activeZone = zones[resolvedActiveIndex];
// --- Geometry (all computed; nothing escapes the padded viewBox). ---
const stroke = 16;
const pad = 18;
const w = size;
const cx = w / 2;
const r = size / 2 - pad - stroke / 2;
// Room above the arc for boundary chips, below the pivot for the readout.
const labelBand = 18;
const cy = pad + r + stroke / 2 + labelBand;
const readoutBand = 66;
const h = cy + readoutBand;
// Polar helper: t in [0,1] maps left (180°) → right (0°) at radius `rad`.
const point = (t: number, rad = r) => {
const angle = Math.PI - t * Math.PI;
return [cx + rad * Math.cos(angle), cy - rad * Math.sin(angle)] as const;
};
// Every arc here spans at most 180°, so the large-arc flag is always 0.
const arcPath = (from: number, to: number) => {
const [x1, y1] = point(from);
const [x2, y2] = point(to);
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 0 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
};
// Needle rests pointing left (t=0) until armed, then rotates to the target.
// At rotation 0 the needle points straight up; -90° → left, +90° → right.
const shownFraction = armed ? clamped : 0;
const needleAngleDeg = -90 + shownFraction * 180;
const needleTipY = cy - (r - stroke / 2 - 4);
// Base sits on the pivot (no tail) so nothing dips into the readout below.
const needleBaseY = cy;
const needleHalf = 3;
const percentFormat = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 0,
}),
[],
);
let cursor = 0;
const segments = zones.map((z) => {
const from = cursor;
cursor = z.upTo;
return { ...z, from, to: z.upTo };
});
// Rim labels at internal zone boundaries. Measure-and-skip: estimate each
// label's box from its anchor and drop any that would intersect an
// already-kept one, so custom zone sets never collide.
const boundaryLabels = React.useMemo(() => {
const internal = segments
.slice(0, -1)
.map((seg) => seg.to)
.filter((t) => t > 0 && t < 1);
const labelRadius = r + stroke / 2 + 10;
const kept: {
t: number;
x: number;
y: number;
anchor: "start" | "middle" | "end";
box: { x0: number; x1: number; y0: number; y1: number };
}[] = [];
for (const t of internal) {
const [x, y] = point(t, labelRadius);
const anchor: "start" | "middle" | "end" =
Math.abs(x - cx) < 8 ? "middle" : x > cx ? "start" : "end";
const textW = percentFormat.format(t).length * 5.7; // ~9.5px tabular
const x0 =
anchor === "middle" ? x - textW / 2 : anchor === "start" ? x : x - textW;
const box = { x0, x1: x0 + textW, y0: y - 5.5, y1: y + 5.5 };
const collides = kept.some(
(k) =>
box.x0 < k.box.x1 + 4 &&
box.x1 > k.box.x0 - 4 &&
box.y0 < k.box.y1 + 2 &&
box.y1 > k.box.y0 - 2,
);
if (!collides) kept.push({ t, x, y, anchor, box });
}
return kept;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(zones.map((z) => z.upTo)), r, cx, cy, stroke]);
const readoutLayer =
"absolute inset-x-0 bottom-0 flex flex-col items-center transition-opacity duration-150";
return (
<div
ref={ref}
data-slot="affordability-gauge"
className={cn("inline-flex flex-col items-center", className)}
{...props}
>
<div
className="relative"
style={{ width: w, height: h }}
onPointerLeave={() => setActive(null)}
>
<svg
role="img"
aria-label={`Affordability gauge: ${formatValue(clamped)}, ${activeZone.label}`}
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
className="block"
>
{/* Track */}
<path
d={arcPath(0, 1)}
fill="none"
stroke="var(--color-secondary)"
strokeWidth={stroke}
/>
{/* Zone segments — butt caps so each boundary gap is exact. */}
{segments.map((seg, i) => {
const from = seg.from + (i === 0 ? 0 : 0.006);
const to =
Math.min(seg.to, 1) - (i === segments.length - 1 ? 0 : 0.006);
const isActive = active === i;
const dimmed = active !== null && !isActive;
if (to <= from) return null;
return (
<path
key={i}
d={arcPath(from, to)}
fill="none"
stroke={seg.color}
strokeWidth={stroke}
tabIndex={0}
role="img"
aria-label={`${seg.label}: ${percentFormat.format(
seg.from,
)}–${percentFormat.format(seg.to)}`}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
onKeyDown={(e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const dir = e.key === "ArrowRight" ? 1 : -1;
const bands = Array.from(
e.currentTarget.parentElement?.querySelectorAll(
"path[role='img'][tabindex='0']",
) ?? [],
) as SVGPathElement[];
const at = bands.indexOf(e.currentTarget);
bands[(at + dir + bands.length) % bands.length]?.focus();
}}
className="cursor-pointer outline-none focus-visible:stroke-foreground"
style={{
opacity: dimmed ? 0.4 : 0.9,
transition:
"opacity 150ms var(--ease-out), stroke 150ms var(--ease-out)",
}}
/>
);
})}
{/* Boundary threshold labels on the rim + 0% / 100% under the ends */}
{boundaryLabels.map(({ t, x, y, anchor }) => (
<text
key={t}
x={x.toFixed(2)}
y={y.toFixed(2)}
textAnchor={anchor}
dominantBaseline="middle"
fontSize={9.5}
className="fill-muted-foreground tabular-nums select-none"
aria-hidden
>
{percentFormat.format(t)}
</text>
))}
<text
x={cx - r}
y={cy + 15}
textAnchor="middle"
fontSize={9.5}
className="fill-muted-foreground tabular-nums select-none"
aria-hidden
>
0%
</text>
<text
x={cx + r}
y={cy + 15}
textAnchor="middle"
fontSize={9.5}
className="fill-muted-foreground tabular-nums select-none"
aria-hidden
>
100%
</text>
{/* Needle — a tapered pointer rotating from the center pivot. Lives
entirely in the upper half, clear of the readout below. */}
<g
style={{
transformOrigin: `${cx}px ${cy}px`,
transform: `rotate(${needleAngleDeg}deg)`,
transition: animate
? "transform 550ms var(--ease-out)"
: undefined,
}}
>
<path
d={`M ${cx - needleHalf} ${needleBaseY} L ${cx - 0.75} ${needleTipY} L ${
cx + 0.75
} ${needleTipY} L ${cx + needleHalf} ${needleBaseY} Z`}
fill="var(--color-foreground)"
stroke="var(--color-foreground)"
strokeWidth={1}
strokeLinejoin="round"
/>
</g>
<circle cx={cx} cy={cy} r={6} fill="var(--color-foreground)" />
<circle cx={cx} cy={cy} r={2.5} fill="var(--color-background)" />
</svg>
{/* Readout — two layers crossfade between the value and the hovered
zone. Anchored below the pivot, in space the needle never enters. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-0"
style={{ height: readoutBand - 6 }}
aria-hidden
>
<div className={cn(readoutLayer, active !== null && "opacity-0")}>
<span className="text-3xl font-semibold tabular-nums">
{formatValue(clamped)}
</span>
<span
className="mt-0.5 text-sm font-medium transition-colors duration-300"
style={{ color: activeZone.color }}
>
{activeZone.label}
</span>
</div>
<div className={cn(readoutLayer, active === null && "opacity-0")}>
{active !== null && (
<>
<span className="text-2xl font-semibold tabular-nums">
{percentFormat.format(segments[active].from)}–
{percentFormat.format(segments[active].to)}
</span>
<span
className="mt-0.5 text-sm font-medium"
style={{ color: segments[active].color }}
>
{segments[active].label}
</span>
</>
)}
</div>
</div>
</div>
{caption && (
<p className="mt-2 max-w-xs text-center text-xs text-muted-foreground">
{caption}
</p>
)}
</div>
);
}