A rent affordability gauge measuring rent as a share of monthly income against the 30% rule with a sweeping radial dial.
npx shadcn@latest add @paragon/rent-affordability"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface RentAffordabilityProps extends React.ComponentProps<"div"> {
/** Gross monthly income. */
monthlyIncome?: number;
/** Proposed monthly rent. */
rent?: number;
/** Affordability threshold as a fraction of income (the "30% rule"). */
threshold?: number;
/** ISO currency. */
currency?: string;
/** Suppresses the gauge sweep animation and number count-up. */
static?: boolean;
}
// Dial geometry — one coordinate space, arc computed from circumference.
const SIZE = 128;
const THICKNESS = 10;
const CX = SIZE / 2;
const CY = SIZE / 2;
const RADIUS = (SIZE - THICKNESS) / 2 - 1;
const CIRC = 2 * Math.PI * RADIUS;
/** Polar→cartesian on the dial circle (0° at 12 o'clock, clockwise). */
function polar(deg: number, r = RADIUS) {
const rad = ((deg - 90) * Math.PI) / 180;
return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad) };
}
/**
* A rent affordability gauge: rent as a share of monthly income measured
* against the 30% rule. A full-circle dial sweeps to the ratio on first view
* (arc offset = C·(1 − ratio), C = 2πr) and tints success / warning /
* destructive by how far past the threshold it lands. A computed tick marks the
* threshold on the ring. The center percentage counts up on a NumberTicker; the
* breakdown uses a fixed tabular-nums value column so labels never overlap the
* figures. Reduced motion (or `static`) renders the final state. Deterministic.
*/
export function RentAffordability({
monthlyIncome = 7200,
rent = 2400,
threshold = 0.3,
currency = "USD",
static: isStatic = false,
className,
...props
}: RentAffordabilityProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const armed = !animate || inView;
const ratio = monthlyIncome > 0 ? rent / monthlyIncome : 0;
const clamped = Math.min(Math.max(ratio, 0), 1);
const over = ratio > threshold;
const wayOver = ratio > threshold * 1.5;
const tone = wayOver
? "var(--color-destructive)"
: over
? "var(--color-warning)"
: "var(--color-success)";
const toneText = wayOver
? "text-destructive"
: over
? "text-warning"
: "text-success";
const verdict = wayOver
? "Unaffordable"
: over
? "Over budget"
: "Affordable";
const money = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 0,
}),
[currency],
);
// Sweep matches the ratio exactly; full offset = empty ring.
const offset = CIRC * (1 - (armed ? clamped : 0));
// Threshold tick, computed on the ring (rotate accounts for the -90° svg).
const tickInner = polar(threshold * 360, RADIUS - THICKNESS / 2 - 2);
const tickOuter = polar(threshold * 360, RADIUS + THICKNESS / 2 + 2);
const budget = monthlyIncome * threshold;
return (
<TooltipProvider>
<div
ref={ref}
data-slot="rent-affordability"
style={{
opacity: armed ? 1 : 0,
transform: armed ? "translateY(0)" : "translateY(12px)",
filter: armed ? "blur(0)" : "blur(4px)",
transition: animate
? "opacity 300ms var(--ease-out), transform 300ms var(--ease-out), filter 300ms var(--ease-out)"
: undefined,
}}
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between gap-3">
<p className="min-w-0 truncate text-sm font-medium">
Rent affordability
</p>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
toneText,
)}
style={{
background: `color-mix(in oklch, ${tone} 12%, transparent)`,
}}
>
{verdict}
</span>
</div>
<div className="mt-3 flex items-center gap-5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Rent is ${Math.round(clamped * 100)}% of income, ${verdict}`}
className="relative shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className="size-32 -rotate-90"
aria-hidden
>
<circle
cx={CX}
cy={CY}
r={RADIUS}
fill="none"
className="stroke-secondary"
strokeWidth={THICKNESS}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={CX}
cy={CY}
r={RADIUS}
fill="none"
stroke={tone}
strokeWidth={THICKNESS}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
strokeDasharray={CIRC}
strokeDashoffset={offset}
style={{
transition: animate
? "stroke-dashoffset 700ms var(--ease-out), stroke 300ms var(--ease-out)"
: "stroke 300ms var(--ease-out)",
}}
/>
{/* Threshold tick, computed on the ring. */}
<line
x1={tickInner.x}
y1={tickInner.y}
x2={tickOuter.x}
y2={tickOuter.y}
className="stroke-foreground/40"
strokeWidth={2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
<span className="absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn(
"text-2xl font-semibold tabular-nums",
toneText,
)}
>
<NumberTicker
value={Math.round(clamped * 100)}
static={isStatic}
formatOptions={{ style: "unit", unit: "percent" }}
/>
</span>
<span className="text-[11px] text-muted-foreground">
of income
</span>
</span>
</button>
</TooltipTrigger>
<TooltipContent>
{money.format(rent)} of {money.format(monthlyIncome)} · tick marks
the {Math.round(threshold * 100)}% rule
</TooltipContent>
</Tooltip>
<dl className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 text-sm">
<dt className="min-w-0 truncate text-muted-foreground">Income</dt>
<dd className="text-right font-medium tabular-nums">
{money.format(monthlyIncome)}/mo
</dd>
<dt className="min-w-0 truncate text-muted-foreground">Rent</dt>
<dd className="text-right font-medium tabular-nums">
{money.format(rent)}/mo
</dd>
<dt className="min-w-0 truncate border-t border-border pt-2 text-muted-foreground">
{Math.round(threshold * 100)}% rule
</dt>
<dd
className={cn(
"border-t border-border pt-2 text-right font-medium tabular-nums",
toneText,
)}
>
{money.format(budget)}
</dd>
</dl>
</div>
</div>
</TooltipProvider>
);
}