Metric Card Group
Data Display

Metric Card Group

A segmented band of KPI cards on one surface with hairline seams, improvement-aware deltas, inline sparklines, staggered first-view reveal, and an optional single-select mode with a gliding indicator.

Install

npx shadcn@latest add @paragon/metric-card-group

Also installs: sparkline, tooltip

metric-card-group.tsx

"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Sparkline } from "@/registry/paragon/ui/sparkline";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export interface MetricCardItem {
  id: string;
  label: string;
  /** Preformatted display value, e.g. "$128.4k". */
  value: string;
  /** Signed percent vs the prior period, e.g. -4.2. */
  delta?: number;
  /** Is a rising delta good? Latency rising is bad: pass "down". */
  positiveIs?: "up" | "down";
  /** Definition shown in a tooltip on the label. */
  hint?: string;
  /** Sparkline series, oldest first. */
  data?: number[];
}

export interface MetricCardGroupProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  items: MetricCardItem[];
  /** Columns at full container width; narrow containers fall back to 2. */
  columns?: 2 | 3 | 4;
  /** Cards become a single-select group (for driving a chart below). */
  selectable?: boolean;
  defaultSelectedId?: string;
  onSelect?: (item: MetricCardItem) => void;
  /** Renders skeleton cells in place of data. */
  loading?: boolean;
  /** Renders cells in place with no reveal or indicator motion. */
  static?: boolean;
}

const COLUMN_CLASS: Record<2 | 3 | 4, string> = {
  2: "grid-cols-2",
  3: "grid-cols-2 @lg:grid-cols-3",
  4: "grid-cols-2 @2xl:grid-cols-4",
};

/**
 * A segmented band of metric cards on one surface — hairline seams, a
 * label with an optional definition tooltip, tabular value, improvement-
 * aware delta, and an inline sparkline tinted by whether the move is
 * good. Cells blur-rise with a stagger on first view. With `selectable`,
 * the band becomes a single-select group (roving tabindex, arrow keys)
 * and the active indicator bar glides between cells — wire `onSelect` to
 * swap the chart underneath.
 */
export function MetricCardGroup({
  items,
  columns = 4,
  selectable = false,
  defaultSelectedId,
  onSelect,
  loading = false,
  static: isStatic = false,
  className,
  ...props
}: MetricCardGroupProps) {
  const uid = React.useId();
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const reducedMotion = useReducedMotion() ?? false;
  const noMotion = reducedMotion || isStatic;
  const shown = isStatic || reducedMotion || inView;

  const [selected, setSelected] = React.useState<string | null>(
    defaultSelectedId ?? (selectable ? (items[0]?.id ?? null) : null),
  );
  const [focused, setFocused] = React.useState<string | null>(null);
  const cellRefs = React.useRef(new Map<string, HTMLButtonElement>());

  const focusId = (id: string | undefined) => {
    if (!id) return;
    setFocused(id);
    cellRefs.current.get(id)?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent, index: number) => {
    const ids = items.map((i) => i.id);
    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        event.preventDefault();
        focusId(ids[(index + 1) % ids.length]);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        event.preventDefault();
        focusId(ids[(index - 1 + ids.length) % ids.length]);
        break;
      case "Home":
        event.preventDefault();
        focusId(ids[0]);
        break;
      case "End":
        event.preventDefault();
        focusId(ids[ids.length - 1]);
        break;
    }
  };

  const focusTarget = focused ?? selected ?? items[0]?.id;

  if (!loading && items.length === 0) {
    return (
      <div
        data-slot="metric-card-group"
        className={cn(
          "flex items-center justify-center rounded-xl border border-dashed px-4 py-10 text-xs text-muted-foreground",
          className,
        )}
        {...props}
      >
        No metrics for this range.
      </div>
    );
  }

  return (
    <TooltipProvider>
      <div className={cn("@container w-full", className)}>
        <div
          ref={ref}
          data-slot="metric-card-group"
          role={selectable ? "radiogroup" : undefined}
          aria-label={selectable ? "Select a metric" : undefined}
          className={cn(
            "grid w-full overflow-hidden rounded-xl bg-card shadow-border",
            COLUMN_CLASS[columns],
          )}
          {...props}
        >
          {loading
            ? Array.from({ length: columns }, (_, i) => (
                <div
                  key={i}
                  aria-hidden
                  className="flex flex-col gap-3 p-4 shadow-[-1px_-1px_0_0_var(--color-border)]"
                >
                  <span
                    className="h-3 w-20 animate-pulse rounded bg-muted motion-reduce:animate-none"
                    style={{ animationDelay: `${i * 100}ms` }}
                  />
                  <span
                    className="h-6 w-24 animate-pulse rounded bg-muted motion-reduce:animate-none"
                    style={{ animationDelay: `${i * 100}ms` }}
                  />
                  <span
                    className="h-3 w-14 animate-pulse rounded bg-muted motion-reduce:animate-none"
                    style={{ animationDelay: `${i * 100}ms` }}
                  />
                </div>
              ))
            : items.map((item, index) => {
                const positiveIs = item.positiveIs ?? "up";
                const improved =
                  item.delta === undefined || item.delta === 0
                    ? null
                    : positiveIs === "up"
                      ? item.delta > 0
                      : item.delta < 0;
                const isSelected = selectable && selected === item.id;

                const cellClassName = cn(
                  "relative flex min-w-0 flex-col items-stretch gap-1 p-4 text-left",
                  "shadow-[-1px_-1px_0_0_var(--color-border)]",
                  "transition-[background-color,opacity,translate] duration-(--duration-base) ease-(--ease-out)",
                  selectable &&
                    "cursor-pointer focus-visible:z-10 focus-visible:-outline-offset-2",
                  selectable && !isSelected && "hover:bg-muted/30",
                  isSelected && "bg-muted/40",
                  // First-view reveal — opacity + rise, staggered.
                  noMotion
                    ? shown
                      ? "opacity-100"
                      : "opacity-0"
                    : shown
                      ? "translate-y-0 opacity-100"
                      : "translate-y-1.5 opacity-0",
                );
                const cellStyle =
                  shown && !noMotion
                    ? { transitionDelay: `${Math.min(index, 8) * 60}ms` }
                    : undefined;

                const content = (
                  <>
                    {isSelected &&
                      (noMotion ? (
                        <span
                          aria-hidden
                          className="absolute inset-x-0 top-0 h-0.5 bg-primary"
                        />
                      ) : (
                        <motion.span
                          aria-hidden
                          layoutId={`${uid}-indicator`}
                          transition={{ type: "spring", duration: 0.35, bounce: 0 }}
                          className="absolute inset-x-0 top-0 h-0.5 bg-primary"
                        />
                      ))}

                    <span className="min-w-0 truncate text-xs text-muted-foreground">
                      {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
                      )}
                    </span>

                    <span className="flex items-end justify-between gap-3">
                      <span
                        className="min-w-0 truncate text-[22px] leading-7 font-semibold tracking-tight tabular-nums"
                        title={item.value}
                      >
                        {item.value}
                      </span>
                      {item.data && item.data.length > 1 && (
                        <Sparkline
                          data={item.data}
                          width={72}
                          height={26}
                          strokeWidth={1.25}
                          trend={
                            improved === null
                              ? "neutral"
                              : improved
                                ? "up"
                                : "down"
                          }
                          static={isStatic}
                          className="mb-0.5"
                          aria-label={`${item.label} trend, latest ${item.value}`}
                        />
                      )}
                    </span>

                    {item.delta !== undefined && (
                      <span
                        className={cn(
                          "inline-flex items-center gap-0.5 text-[12px] font-medium tabular-nums",
                          improved === null
                            ? "text-muted-foreground"
                            : improved
                              ? "text-success"
                              : "text-destructive",
                        )}
                      >
                        {item.delta === 0 ? null : item.delta > 0 ? (
                          <ArrowUpRight aria-hidden className="size-3.5" />
                        ) : (
                          <ArrowDownRight aria-hidden className="size-3.5" />
                        )}
                        {item.delta > 0 ? "+" : item.delta < 0 ? "−" : ""}
                        {Math.abs(item.delta).toFixed(1)}%
                        <span className="ml-0.5 font-normal text-muted-foreground">
                          vs last period
                        </span>
                      </span>
                    )}
                  </>
                );

                return selectable ? (
                  <button
                    key={item.id}
                    type="button"
                    role="radio"
                    aria-checked={isSelected}
                    tabIndex={focusTarget === item.id ? 0 : -1}
                    ref={(el) => {
                      if (el) cellRefs.current.set(item.id, el);
                      else cellRefs.current.delete(item.id);
                    }}
                    onClick={() => {
                      setSelected(item.id);
                      setFocused(item.id);
                      onSelect?.(item);
                    }}
                    onKeyDown={(event) => handleKeyDown(event, index)}
                    onFocus={() => setFocused(item.id)}
                    className={cellClassName}
                    style={cellStyle}
                  >
                    {content}
                  </button>
                ) : (
                  <div key={item.id} className={cellClassName} style={cellStyle}>
                    {content}
                  </div>
                );
              })}
        </div>
      </div>
    </TooltipProvider>
  );
}