Data Display

Metric Delta

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.

Install

npx shadcn@latest add @paragon/metric-delta

metric-delta.tsx

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