Stat Compare
Data Display

Stat Compare

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.

Install

npx shadcn@latest add @paragon/stat-compare

Also installs: tooltip

stat-compare.tsx

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