PropTech

Rent Affordability

A rent affordability gauge measuring rent as a share of monthly income against the 30% rule with a sweeping radial dial.

Install

npx shadcn@latest add @paragon/rent-affordability

rent-affordability.tsx

"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>
  );
}