PropTech

Home Value Estimate

An estimated home-value card with a low-to-high confidence band and a value-history sparkline that traces in on mount.

Install

npx shadcn@latest add @paragon/home-value-estimate

home-value-estimate.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { TrendingUp, TrendingDown, Info } from "lucide-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 ValuePoint {
  /** Short axis label, e.g. "Jan". */
  label: string;
  value: number;
}

export interface HomeValueEstimateProps extends React.ComponentProps<"div"> {
  /** Point estimate. */
  estimate: number;
  /** Low/high of the confidence range. */
  low: number;
  high: number;
  /** Value history (oldest → newest) for the trend chart. */
  history: ValuePoint[];
  /** ISO currency. */
  currency?: string;
  /** Suppresses the trend trace + count-up animation. */
  static?: boolean;
}

const H = 96;
const PAD = { top: 10, right: 4, bottom: 4, left: 4 };

/**
 * An estimated home-value card: a count-up point estimate with a low–high
 * confidence band (a marker on a range track) and an interactive value-trend
 * chart. Every point is placed from one shared linear scale so the line, the
 * gradient fill, and the hover dot align exactly. The line traces in on mount
 * (stroke-dashoffset), then hovering snaps a crosshair + tooltip to the nearest
 * month; points are keyboard focusable. Reduced motion renders the final state.
 * Deterministic — all values are props.
 */
export function HomeValueEstimate({
  estimate,
  low,
  high,
  history,
  currency = "USD",
  static: isStatic = false,
  className,
  ...props
}: HomeValueEstimateProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const gid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;
  const inView = useInView(containerRef, {
    once: true,
    margin: "0px 0px -32px 0px",
  });

  const [width, setWidth] = React.useState(0);
  const [drawn, setDrawn] = React.useState(!animate);
  const [tip, setTip] = React.useState<{ index: number; visible: boolean }>({
    index: history.length - 1,
    visible: false,
  });

  React.useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  React.useEffect(() => {
    if (!animate || !inView) return;
    const id = requestAnimationFrame(() => setDrawn(true));
    return () => cancelAnimationFrame(id);
  }, [animate, inView]);

  const money = React.useMemo(
    () =>
      new Intl.NumberFormat("en-US", {
        style: "currency",
        currency,
        maximumFractionDigits: 0,
      }),
    [currency],
  );

  const markerPct =
    high > low ? ((estimate - low) / (high - low)) * 100 : 50;

  const n = history.length;
  const hasData = n >= 2;

  const scale = React.useMemo(() => {
    if (!hasData) return null;
    const values = history.map((p) => p.value);
    const min = Math.min(...values);
    const max = Math.max(...values);
    const span = max - min || 1;
    const innerW = Math.max(width - PAD.left - PAD.right, 0);
    const innerH = H - PAD.top - PAD.bottom;
    const xFor = (i: number) => PAD.left + (i / (n - 1)) * innerW;
    const yFor = (v: number) =>
      PAD.top + (1 - (v - min) / span) * innerH;
    return { xFor, yFor, innerW, innerH };
  }, [hasData, history, width, n]);

  const geometry = React.useMemo(() => {
    if (!scale) return null;
    const pts = history.map((p, i) => [scale.xFor(i), scale.yFor(p.value)] as const);
    const line = pts
      .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`)
      .join("");
    const baseline = H - PAD.bottom;
    const area = `${line}L${pts[pts.length - 1][0].toFixed(2)} ${baseline}L${pts[0][0].toFixed(2)} ${baseline}Z`;
    return { pts, line, area };
  }, [scale, history]);

  const first = history[0]?.value ?? 0;
  const last = history[n - 1]?.value ?? 0;
  const up = last >= first;
  const changePct = first ? ((last - first) / first) * 100 : 0;

  const trendColor = up ? "text-success" : "text-destructive";
  const trendStroke = up ? "var(--color-success)" : "var(--color-destructive)";

  const setIndex = React.useCallback(
    (index: number) =>
      setTip({ index: Math.min(n - 1, Math.max(0, index)), visible: true }),
    [n],
  );

  const handleMove = (event: React.PointerEvent<SVGSVGElement>) => {
    if (!scale || scale.innerW <= 0) return;
    const rect = event.currentTarget.getBoundingClientRect();
    const px = event.clientX - rect.left;
    const fraction = (px - PAD.left) / scale.innerW;
    setIndex(Math.round(fraction * (n - 1)));
  };

  const active = history[tip.index];
  const flip = scale ? tip.index > n / 2 : false;

  return (
    <TooltipProvider>
      <div
        ref={containerRef}
        data-slot="home-value-estimate"
        className={cn(
          "w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div className="flex items-center gap-1.5">
          <p className="text-xs font-medium text-muted-foreground">
            Estimated value
          </p>
          <Tooltip>
            <TooltipTrigger asChild>
              <button
                type="button"
                aria-label="How this estimate is calculated"
                className="pressable -m-1 inline-flex items-center justify-center rounded-full p-1 text-muted-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
              >
                <Info aria-hidden className="size-3.5" />
              </button>
            </TooltipTrigger>
            <TooltipContent side="top">
              Modeled from comparable sales and recent trends.
            </TooltipContent>
          </Tooltip>
        </div>

        <div className="mt-1 flex items-baseline gap-2">
          <p className="text-3xl font-semibold tabular-nums">
            <NumberTicker
              value={estimate}
              static={isStatic}
              formatOptions={{
                style: "currency",
                currency,
                maximumFractionDigits: 0,
              }}
            />
          </p>
          <Tooltip>
            <TooltipTrigger asChild>
              <span
                tabIndex={0}
                className={cn(
                  "flex cursor-default items-center gap-0.5 rounded text-xs font-medium tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  trendColor,
                )}
              >
                {up ? (
                  <TrendingUp aria-hidden className="size-3.5" />
                ) : (
                  <TrendingDown aria-hidden className="size-3.5" />
                )}
                {up ? "+" : ""}
                {changePct.toFixed(1)}%
              </span>
            </TooltipTrigger>
            <TooltipContent side="top">
              {up ? "Up" : "Down"} {money.format(Math.abs(last - first))} since{" "}
              {history[0]?.label}
            </TooltipContent>
          </Tooltip>
        </div>

        {/* range band */}
        <div className="mt-4">
          <div className="relative h-1.5 rounded-full bg-secondary">
            <div
              aria-hidden
              className="absolute inset-y-0 rounded-full bg-primary/25"
              style={{ left: "8%", right: "8%" }}
            />
            <Tooltip>
              <TooltipTrigger asChild>
                <button
                  type="button"
                  aria-label={`Estimate ${money.format(estimate)} within ${money.format(low)} to ${money.format(high)}`}
                  className="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary outline-none ring-2 ring-card transition-transform duration-150 ease-out hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring"
                  style={{ left: `${Math.min(Math.max(markerPct, 0), 100)}%` }}
                />
              </TooltipTrigger>
              <TooltipContent side="top">
                {money.format(estimate)} estimate
              </TooltipContent>
            </Tooltip>
          </div>
          <div className="mt-1.5 flex justify-between text-[11px] tabular-nums text-muted-foreground">
            <span>{money.format(low)}</span>
            <span>{money.format(high)}</span>
          </div>
        </div>

        {/* trend chart */}
        <div className="relative mt-3">
          {!hasData ? (
            <div
              style={{ height: H }}
              className="flex items-center justify-center rounded-lg border border-dashed text-xs text-muted-foreground"
            >
              Not enough history yet
            </div>
          ) : width > 0 && geometry && scale ? (
            <>
              <svg
                width={width}
                height={H}
                viewBox={`0 0 ${width} ${H}`}
                preserveAspectRatio="none"
                role="img"
                aria-label={`Value history from ${history[0].label} to ${history[n - 1].label}`}
                className="block touch-none overflow-visible"
                onPointerMove={handleMove}
                onPointerLeave={() => setTip((t) => ({ ...t, visible: false }))}
              >
                <defs>
                  <linearGradient
                    id={`hve-fill-${gid}`}
                    x1="0"
                    y1="0"
                    x2="0"
                    y2="1"
                  >
                    <stop offset="0%" stopColor={trendStroke} stopOpacity={0.18} />
                    <stop offset="100%" stopColor={trendStroke} stopOpacity={0} />
                  </linearGradient>
                  <clipPath id={`hve-clip-${gid}`}>
                    <rect
                      x="0"
                      y="0"
                      height={H}
                      width={drawn ? width : 0}
                      style={{
                        transition: animate
                          ? "width 900ms var(--ease-out)"
                          : undefined,
                      }}
                    />
                  </clipPath>
                </defs>

                <g clipPath={`url(#hve-clip-${gid})`}>
                  <path d={geometry.area} fill={`url(#hve-fill-${gid})`} />
                  <path
                    d={geometry.line}
                    fill="none"
                    stroke={trendStroke}
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    vectorEffect="non-scaling-stroke"
                  />
                </g>

                {tip.visible && active && (
                  <line
                    x1={scale.xFor(tip.index)}
                    x2={scale.xFor(tip.index)}
                    y1={PAD.top}
                    y2={H - PAD.bottom}
                    stroke="currentColor"
                    strokeOpacity={0.22}
                    vectorEffect="non-scaling-stroke"
                    aria-hidden
                  />
                )}
                {tip.visible && active && (
                  <circle
                    cx={scale.xFor(tip.index)}
                    cy={scale.yFor(active.value)}
                    r={3.5}
                    fill="var(--color-background)"
                    stroke={trendStroke}
                    strokeWidth={2}
                    vectorEffect="non-scaling-stroke"
                    aria-hidden
                  />
                )}

                {/* keyboard-focusable hit targets */}
                {history.map((p, i) => {
                  const stepW = scale.innerW / (n - 1);
                  return (
                    <rect
                      key={p.label + i}
                      x={scale.xFor(i) - stepW / 2}
                      y={0}
                      width={stepW}
                      height={H}
                      fill="transparent"
                      tabIndex={0}
                      role="button"
                      aria-label={`${p.label}: ${money.format(p.value)}`}
                      className="cursor-pointer outline-none [&:focus-visible]:stroke-ring"
                      style={{ strokeWidth: 2 }}
                      onFocus={() => setIndex(i)}
                      onBlur={() => setTip((t) => ({ ...t, visible: false }))}
                      onPointerEnter={() => setIndex(i)}
                      onKeyDown={(e) => {
                        const els =
                          e.currentTarget.parentElement?.querySelectorAll<SVGRectElement>(
                            "rect[role='button']",
                          );
                        if (!els) return;
                        if (e.key === "ArrowRight") {
                          e.preventDefault();
                          els[Math.min(n - 1, i + 1)]?.focus();
                        } else if (e.key === "ArrowLeft") {
                          e.preventDefault();
                          els[Math.max(0, i - 1)]?.focus();
                        }
                      }}
                    />
                  );
                })}
              </svg>

              {/* Tooltip */}
              <div
                aria-hidden
                className="pointer-events-none absolute top-0 left-0 z-10 whitespace-nowrap rounded-lg bg-popover px-2.5 py-1.5 text-xs shadow-overlay transition-opacity duration-100"
                style={{
                  opacity: tip.visible && active ? 1 : 0,
                  transform: `translate(${scale.xFor(tip.index)}px, ${scale.yFor(active?.value ?? 0)}px) translate(${flip ? "calc(-100% - 10px)" : "10px"}, -50%)`,
                }}
              >
                <div className="text-muted-foreground">{active?.label}</div>
                <div className="mt-0.5 font-medium tabular-nums">
                  {money.format(active?.value ?? 0)}
                </div>
              </div>
            </>
          ) : (
            <div style={{ height: H }} />
          )}
        </div>
      </div>
    </TooltipProvider>
  );
}