An estimated home-value card with a low-to-high confidence band and a value-history sparkline that traces in on mount.
npx shadcn@latest add @paragon/home-value-estimate"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>
);
}