An inline metric-change chip whose arrow nudges on mount and flips with the sign, digits roll individually on change, and colors can invert for down-is-good metrics.
npx shadcn@latest add @paragon/metric-delta"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
/**
* One digit column: all ten digits stacked in a 1em-tall window, the
* column translated to the current digit. Changing the value retargets
* the transform — an interruptible CSS transition, not a keyframe.
*/
function RollingDigit({ digit }: { digit: number }) {
return (
<span className="inline-flex h-[1em] overflow-hidden">
<span
className="flex flex-col transition-transform duration-(--duration-base) ease-(--ease-out)"
style={{ transform: `translateY(${-digit}em)` }}
>
{DIGITS.map((d) => (
<span key={d} className="flex h-[1em] items-center justify-center">
{d}
</span>
))}
</span>
</span>
);
}
type Tone = "positive" | "negative" | "neutral";
const toneStyles: Record<Tone, string> = {
positive: "bg-success/10 text-success dark:bg-success/15",
negative: "bg-destructive/10 text-destructive dark:bg-destructive/15",
neutral: "bg-muted text-muted-foreground",
};
/** Maps signed value + inversion to the semantic tone. */
function toneFor(value: number, invert: boolean): Tone {
const positive = invert ? value < 0 : value > 0;
const negative = invert ? value > 0 : value < 0;
return positive ? "positive" : negative ? "negative" : "neutral";
}
export interface MetricDeltaBadgeProps extends React.ComponentProps<"span"> {
/** Signed change, e.g. `12.4` renders as ↗ 12.4%. */
value: number;
/** Fraction digits. */
precision?: number;
/** Unit appended after the number. */
suffix?: string;
/** For metrics where down is good (churn, latency, error rate). */
invertColor?: boolean;
/** Renders value changes instantly with no roll or arrow motion. */
static?: boolean;
}
/**
* An inline metric-change chip. The arrow nudges in from its travel
* direction once on mount and blur-swaps when the sign flips; value
* changes roll digit-wise in tabular figures so the chip never shifts
* layout. Direction is conveyed by the accessible label, never color
* alone.
*/
export function MetricDeltaBadge({
value,
precision = 1,
suffix = "%",
invertColor = false,
static: isStatic = false,
className,
...props
}: MetricDeltaBadgeProps) {
const reduced = useReducedMotion();
const plain = isStatic || !!reduced;
const direction: "up" | "down" | "flat" =
value > 0 ? "up" : value < 0 ? "down" : "flat";
const tone = toneFor(value, invertColor);
const formatted = `${Math.abs(value).toFixed(precision)}${suffix}`;
const label = `${
direction === "up" ? "Up" : direction === "down" ? "Down" : "Unchanged"
} ${formatted}`;
const arrowIcon =
direction === "up" ? (
<ArrowUpRight className="size-3" strokeWidth={2.5} />
) : direction === "down" ? (
<ArrowDownRight className="size-3" strokeWidth={2.5} />
) : (
<Minus className="size-3" strokeWidth={2.5} />
);
return (
<span
data-slot="metric-delta-badge"
role="img"
aria-label={label}
className={cn(
"inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-xs leading-none font-medium tabular-nums transition-colors duration-(--duration-quick) ease-(--ease-out)",
toneStyles[tone],
className,
)}
{...props}
>
<span aria-hidden className="flex size-3 items-center justify-center">
{plain ? (
arrowIcon
) : (
<AnimatePresence mode="popLayout">
<motion.span
key={direction}
className="flex"
initial={{
opacity: 0,
y: direction === "up" ? 4 : direction === "down" ? -4 : 0,
filter: "blur(2px)",
}}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{arrowIcon}
</motion.span>
</AnimatePresence>
)}
</span>
<span aria-hidden className="flex items-center leading-none">
{plain
? formatted
: formatted
.split("")
.map((char, i) =>
/\d/.test(char) ? (
<RollingDigit key={i} digit={Number(char)} />
) : (
<span key={i}>{char}</span>
),
)}
</span>
</span>
);
}
/* ------------------------------------------------------------------ */
/* Sparkline */
/* ------------------------------------------------------------------ */
/** Builds an SVG polyline path from a series, scaled into [0..w] × [0..h]. */
function useSparkGeometry(
series: number[],
width: number,
height: number,
pad: number,
) {
return React.useMemo(() => {
const n = series.length;
if (n === 0) {
return { line: "", area: "", points: [] as { x: number; y: number }[] };
}
const min = Math.min(...series);
const max = Math.max(...series);
const span = max - min || 1;
const innerW = width - pad * 2;
const innerH = height - pad * 2;
const points = series.map((v, i) => ({
x: pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW),
y: pad + innerH - ((v - min) / span) * innerH,
}));
const line = points
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
const first = points[0];
const last = points[points.length - 1];
const area = `${line} L${last.x.toFixed(2)} ${(height - pad).toFixed(
2,
)} L${first.x.toFixed(2)} ${(height - pad).toFixed(2)} Z`;
return { line, area, points };
}, [series, width, height, pad]);
}
export interface SparklineProps
extends Omit<React.ComponentProps<"svg">, "children"> {
/** The mini series, oldest → newest. */
data: number[];
/** Semantic tone; drives the stroke and area color. */
tone?: Tone;
/** Renders the full line immediately with no draw-in. */
static?: boolean;
/** Fill the area under the line with a faint tinted gradient. */
area?: boolean;
}
const strokeTone: Record<Tone, string> = {
positive: "text-success",
negative: "text-destructive",
neutral: "text-muted-foreground",
};
/**
* A hand-built trend sparkline. Coordinates are computed by scaling the
* series into an exact viewBox — never eyeballed. The stroke uses
* `non-scaling-stroke` with round caps/joins so it stays crisp at any
* rendered size, and draws in once on first in-view via stroke-dashoffset
* (transform-safe). Reduced motion shows the final line immediately.
*/
export function Sparkline({
data,
tone = "neutral",
static: isStatic = false,
area = true,
className,
...props
}: SparklineProps) {
const W = 100;
const H = 32;
const PAD = 3;
const ref = React.useRef<SVGSVGElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const reduced = useReducedMotion() ?? false;
const noMotion = isStatic || reduced;
const gradientId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const { line, area: areaPath, points } = useSparkGeometry(data, W, H, PAD);
const drawn = noMotion || inView;
const last = points[points.length - 1];
return (
<svg
ref={ref}
data-slot="sparkline"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
role="img"
aria-label="Trend"
className={cn("h-8 w-full overflow-visible", strokeTone[tone], className)}
{...props}
>
<defs>
<linearGradient id={`spark-${gradientId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.18} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
{area && areaPath && (
<path
d={areaPath}
fill={`url(#spark-${gradientId})`}
className={cn(
"transition-opacity duration-(--duration-slow) ease-(--ease-out)",
drawn ? "opacity-100" : "opacity-0",
)}
/>
)}
{line && (
<path
d={line}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
style={{
vectorEffect: "non-scaling-stroke",
strokeDasharray: 1,
strokeDashoffset: drawn ? 0 : 1,
transition: noMotion
? undefined
: "stroke-dashoffset var(--duration-slow) var(--ease-out)",
}}
/>
)}
{last && (
<circle
cx={last.x}
cy={last.y}
r={1.6}
fill="currentColor"
className={cn(
"transition-opacity duration-(--duration-base) ease-(--ease-out)",
drawn ? "opacity-100" : "opacity-0",
)}
style={{ transitionDelay: drawn && !noMotion ? "350ms" : undefined }}
/>
)}
</svg>
);
}
/* ------------------------------------------------------------------ */
/* MetricDelta — the composite KPI stat */
/* ------------------------------------------------------------------ */
export interface MetricDeltaProps
extends Omit<React.ComponentProps<"div">, "title"> {
/** Metric name shown above the value. */
label: string;
/** The formatted value string, e.g. `"$86,240"` or `"4,218"`. */
value: React.ReactNode;
/** Signed period-over-period change for the delta badge. */
delta: number;
/** Delta unit; appended after the number. */
deltaSuffix?: string;
/** For metrics where down is good (churn, latency, error rate). */
invertColor?: boolean;
/** Optional trend series for the inline sparkline. */
trend?: number[];
/** Renders everything statically, no roll or draw-in. */
static?: boolean;
}
/**
* A KPI stat card: label, a large tabular value, a sign-colored delta
* badge, and an optional trend sparkline whose tone matches the delta.
* Zero-config beautiful in both themes.
*/
export function MetricDelta({
label,
value,
delta,
deltaSuffix = "%",
invertColor = false,
trend,
static: isStatic = false,
className,
...props
}: MetricDeltaProps) {
const tone = toneFor(delta, invertColor);
return (
<div
data-slot="metric-delta"
className={cn(
"flex flex-col gap-2 rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-semibold tracking-tight tabular-nums">
{value}
</span>
<MetricDeltaBadge
value={delta}
suffix={deltaSuffix}
invertColor={invertColor}
static={isStatic}
className="shrink-0"
/>
</div>
{trend && trend.length > 1 && (
<Sparkline data={trend} tone={tone} static={isStatic} className="mt-1" />
)}
</div>
);
}