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 affordability gauge. Zone segments are painted as
* parametric arc paths (polar → cartesian, exact start/end fractions) and a
* needle sweeps to the current ratio. The needle uses an interruptible CSS
* rotate transition, so retargeting mid-sweep redirects rather than restarts;
* the sweep runs once when the gauge enters view.
*
* Interactive: hover or keyboard-focus a zone band to reveal 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];
const w = size;
const h = size / 2 + 24;
const cx = w / 2;
const cy = size / 2 + 4;
const r = size / 2 - 16;
const stroke = 14;
// Polar helper: t in [0,1] maps left (180°) → right (0°).
const point = (t: number) => {
const angle = Math.PI - t * Math.PI;
return [cx + r * Math.cos(angle), cy - r * Math.sin(angle)] as const;
};
const arcPath = (from: number, to: number) => {
const [x1, y1] = point(from);
const [x2, y2] = point(to);
const large = to - from > 0.5 ? 1 : 0;
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
};
// Needle rests at the bottom-left (pointing to t=0) until armed, then
// rotates to the target — an interruptible transition retargets mid-sweep.
const shownFraction = armed ? clamped : 0;
const needleAngleDeg = -90 + shownFraction * 180;
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 };
});
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 overflow-visible"
>
{/* Track */}
<path
d={arcPath(0, 1)}
fill="none"
stroke="var(--color-secondary)"
strokeWidth={stroke}
strokeLinecap="round"
/>
{/* Zone segments — parametric arcs with small visual gaps */}
{segments.map((seg, i) => {
const from = seg.from + (i === 0 ? 0 : 0.004);
const to =
Math.min(seg.to, 1) - (i === segments.length - 1 ? 0 : 0.004);
const isActive = active === i;
const dimmed = active !== null && !isActive;
return (
<path
key={i}
d={arcPath(from, to)}
fill="none"
stroke={seg.color}
strokeWidth={stroke}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
tabIndex={0}
role="button"
aria-label={`${seg.label}: ${percentFormat.format(
seg.from,
)}–${percentFormat.format(seg.to)}`}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
className="cursor-pointer outline-none focus-visible:stroke-foreground"
style={{
opacity: dimmed ? 0.4 : 0.9,
transition: "opacity 150ms var(--ease-out)",
}}
/>
);
})}
{/* Needle — rotates from center pivot */}
<g
style={{
transformOrigin: `${cx}px ${cy}px`,
transform: `rotate(${needleAngleDeg}deg)`,
transition: animate
? "transform 550ms var(--ease-out)"
: undefined,
}}
>
<line
x1={cx}
y1={cy}
x2={cx}
y2={cy - r + stroke / 2 + 2}
stroke="var(--color-foreground)"
strokeWidth={2.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</g>
<circle cx={cx} cy={cy} r={5} fill="var(--color-foreground)" />
<circle cx={cx} cy={cy} r={2} fill="var(--color-background)" />
</svg>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center">
{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>
</>
) : (
<>
<span className="text-3xl font-semibold tabular-nums">
{formatValue(clamped)}
</span>
<span
className="mt-0.5 text-sm font-medium"
style={{ color: activeZone.color }}
>
{activeZone.label}
</span>
</>
)}
</div>
</div>
{caption && (
<p className="mt-2 max-w-xs text-center text-xs text-muted-foreground">
{caption}
</p>
)}
</div>
);
}