Period-over-period metric rows with paired normalized bars that grow in on view, fixed tabular value columns, and improvement-aware delta chips that invert for down-is-good metrics.
npx shadcn@latest add @paragon/stat-compareAlso installs: tooltip
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface StatCompareItem {
label: string;
/** Baseline value (the earlier period / control cohort). */
a: number;
/** Comparison value (the current period / variant). */
b: number;
/** Formats both values for display. Defaults to locale grouping. */
format?: (value: number) => string;
/**
* Which direction is an improvement — drives the delta color.
* Latency and churn falling is good: pass "down". Default "up".
*/
positiveIs?: "up" | "down" | "neutral";
/** Extra context shown in a tooltip on the label. */
hint?: string;
}
export interface StatCompareProps extends React.ComponentProps<"div"> {
items: StatCompareItem[];
/** Column label for the baseline values. */
labelA?: string;
/** Column label for the comparison values. */
labelB?: string;
/** Renders skeleton rows in place of data. */
loading?: boolean;
/** Skeleton row count while loading. */
loadingRows?: number;
/** Renders bars at full width immediately, no reveal. */
static?: boolean;
}
const defaultFormat = (value: number) =>
value.toLocaleString("en-US", { maximumFractionDigits: 2 });
function deltaOf(a: number, b: number): number | null {
if (a === 0) return null;
return ((b - a) / Math.abs(a)) * 100;
}
/**
* Period-over-period metric comparison rows. Each metric shows the two
* values in fixed tabular columns, a paired bar normalized to the row's
* larger value, and a signed delta chip colored by whether the move is an
* improvement (`positiveIs` inverts it for churn-like metrics). Bars grow
* from the left with a short stagger the first time the block scrolls into
* view; a zero baseline renders a "New" chip instead of a fake percent.
*/
export function StatCompare({
items,
labelA = "Previous",
labelB = "Current",
loading = false,
loadingRows = 4,
static: isStatic = false,
className,
...props
}: StatCompareProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const reducedMotion = useReducedMotion() ?? false;
const drawn = isStatic || reducedMotion || inView;
const animate = !isStatic && !reducedMotion;
return (
<TooltipProvider>
<div
ref={ref}
data-slot="stat-compare"
className={cn("w-full", className)}
{...props}
>
<div className="mb-2 flex items-center justify-end gap-4 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden
className="size-2 rounded-[3px] bg-muted-foreground/30"
/>
{labelA}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-2 rounded-[3px] bg-primary" />
{labelB}
</span>
</div>
{loading ? (
<div aria-busy="true" aria-label="Loading comparison">
{Array.from({ length: loadingRows }, (_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b py-3 last:border-b-0"
>
<span className="h-3.5 w-24 animate-pulse rounded bg-muted motion-reduce:animate-none" />
<span
className="h-2 flex-1 animate-pulse rounded bg-muted motion-reduce:animate-none"
style={{ animationDelay: `${i * 100}ms` }}
/>
<span className="h-3.5 w-28 animate-pulse rounded bg-muted motion-reduce:animate-none" />
</div>
))}
</div>
) : items.length === 0 ? (
<p className="rounded-lg border border-dashed px-4 py-8 text-center text-xs text-muted-foreground">
No metrics for this range.
</p>
) : (
<dl>
{items.map((item, index) => {
const format = item.format ?? defaultFormat;
const positiveIs = item.positiveIs ?? "up";
const delta = deltaOf(item.a, item.b);
const improved =
delta === null || positiveIs === "neutral"
? null
: positiveIs === "up"
? delta > 0
: delta < 0;
const max = Math.max(Math.abs(item.a), Math.abs(item.b)) || 1;
const delay = animate ? Math.min(index, 8) * 60 : 0;
const label = (
<dt
className="min-w-0 truncate text-[13px] text-foreground"
title={item.label}
>
{item.hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default underline decoration-border decoration-dotted underline-offset-4">
{item.label}
</span>
</TooltipTrigger>
<TooltipContent side="top">{item.hint}</TooltipContent>
</Tooltip>
) : (
item.label
)}
</dt>
);
return (
<div
key={item.label}
className="grid grid-cols-[minmax(0,10rem)_minmax(3rem,1fr)_auto] items-center gap-x-4 border-b py-3 last:border-b-0"
>
{label}
{/* Paired bars, normalized to the row's larger value. */}
<div className="flex min-w-0 flex-col gap-1" aria-hidden>
<span className="h-1.5 w-full overflow-hidden rounded-full bg-muted/60">
<span
className="block h-full origin-left rounded-full bg-muted-foreground/30"
style={{
transform: `scaleX(${drawn ? Math.abs(item.a) / max : 0})`,
transition: animate
? `transform 500ms var(--ease-out) ${delay}ms`
: undefined,
}}
/>
</span>
<span className="h-1.5 w-full overflow-hidden rounded-full bg-muted/60">
<span
className="block h-full origin-left rounded-full bg-primary"
style={{
transform: `scaleX(${drawn ? Math.abs(item.b) / max : 0})`,
transition: animate
? `transform 500ms var(--ease-out) ${delay + 80}ms`
: undefined,
}}
/>
</span>
</div>
<dd className="flex items-center justify-end gap-3">
<span className="text-right text-[13px] text-muted-foreground tabular-nums">
{format(item.a)}
</span>
<span
aria-hidden
className="text-[11px] text-muted-foreground/50"
>
→
</span>
<span className="min-w-14 text-right text-[13px] font-medium tabular-nums">
{format(item.b)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex w-[4.5rem] cursor-default items-center justify-end gap-0.5 rounded-full text-[12px] font-medium tabular-nums",
improved === null
? "text-muted-foreground"
: improved
? "text-success"
: "text-destructive",
)}
>
{delta === null ? (
"New"
) : (
<>
{delta === 0 ? (
<Minus aria-hidden className="size-3" />
) : delta > 0 ? (
<ArrowUpRight aria-hidden className="size-3.5" />
) : (
<ArrowDownRight
aria-hidden
className="size-3.5"
/>
)}
{Math.abs(delta) >= 100
? `${Math.round(Math.abs(delta))}%`
: `${Math.abs(delta).toFixed(1)}%`}
</>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{delta === null
? `No ${labelA.toLowerCase()} baseline`
: `${format(item.a)} → ${format(item.b)} vs ${labelA.toLowerCase()}`}
</TooltipContent>
</Tooltip>
</dd>
</div>
);
})}
</dl>
)}
</div>
</TooltipProvider>
);
}