Trend Cell
Charts

Trend Cell

A table-cell metric that sits a value, improvement-aware delta chip, and micro sparkline on one shared baseline, flashing a directional wash on every update.

Install

npx shadcn@latest add @paragon/trend-cell

Also installs: highlight-on-update, sparkline, tooltip

trend-cell.tsx

"use client";

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

export interface TrendCellProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** Current value of the metric. */
  value: number;
  formatValue?: (value: number) => string;
  /** Signed percent vs the prior period. Computed from `data` when omitted. */
  delta?: number;
  /** Is a rising delta good? Latency rising is bad: pass "down". */
  positiveIs?: "up" | "down";
  /** Recent history, oldest first — rendered as the micro sparkline. */
  data?: number[];
  /** Explains the delta comparison in the chip's tooltip. */
  deltaLabel?: string;
  sparkWidth?: number;
  /** Explicit spark stroke. Defaults to the improvement-aware tint. */
  color?: string;
  /** Soft background wash on value change. */
  flash?: boolean;
  /** Renders without draw-in animation. */
  static?: boolean;
}

/**
 * A table-cell metric: value, improvement-aware delta chip, and a micro
 * sparkline, all sharing one text baseline so rows scan as a single line.
 * Value changes flash a directional wash (green when the move is good for
 * this metric, red when bad — `positiveIs="down"` inverts for
 * latency-style metrics) that retargets rather than restarts under rapid
 * updates. The chip explains its comparison in a tooltip, and the spark is
 * its own keyboard stop with per-point readouts. Numbers are tabular so
 * live updates never shift layout.
 */
export function TrendCell({
  value,
  formatValue = (v) => v.toLocaleString("en-US"),
  delta,
  positiveIs = "up",
  data,
  deltaLabel = "vs prior period",
  sparkWidth = 64,
  color,
  flash = true,
  static: isStatic = false,
  className,
  ...props
}: TrendCellProps) {
  // Direction of the latest change, for the wash tint.
  const previous = React.useRef(value);
  const direction = React.useRef<"up" | "down" | null>(null);
  if (!Object.is(previous.current, value)) {
    direction.current = value >= previous.current ? "up" : "down";
    previous.current = value;
  }

  const computedDelta =
    delta ??
    (data && data.length > 1 && data[0] !== 0
      ? ((data[data.length - 1] - data[0]) / Math.abs(data[0])) * 100
      : undefined);

  const deltaIsGood =
    computedDelta === undefined
      ? null
      : (computedDelta >= 0) === (positiveIs === "up");
  const moveIsGood =
    direction.current === null
      ? null
      : (direction.current === "up") === (positiveIs === "up");

  const sparkColor =
    color ??
    (deltaIsGood === null
      ? "var(--color-muted-foreground)"
      : deltaIsGood
        ? "var(--color-success)"
        : "var(--color-destructive)");

  const valueEl = (
    <span className="text-sm font-medium tabular-nums">{formatValue(value)}</span>
  );

  return (
    <span
      data-slot="trend-cell"
      className={cn("inline-flex items-baseline gap-2", className)}
      {...props}
    >
      {flash ? (
        <HighlightOnUpdate
          value={value}
          color={
            moveIsGood === null ? "neutral" : moveIsGood ? "positive" : "negative"
          }
        >
          {valueEl}
        </HighlightOnUpdate>
      ) : (
        valueEl
      )}

      {computedDelta !== undefined && (
        <TooltipProvider delayDuration={300}>
          <Tooltip>
            <TooltipTrigger asChild>
              <span
                tabIndex={0}
                className={cn(
                  "inline-flex cursor-default items-center gap-0.5 rounded-full px-1.5 py-px text-[10px] font-medium tabular-nums outline-none transition-[background-color,color] duration-(--duration-quick) ease-(--ease-out) focus-visible:ring-2 focus-visible:ring-ring",
                  deltaIsGood
                    ? "bg-success/12 text-success"
                    : "bg-destructive/12 text-destructive",
                )}
                aria-label={`${computedDelta >= 0 ? "Up" : "Down"} ${Math.abs(
                  computedDelta,
                ).toLocaleString("en-US", { maximumFractionDigits: 1 })}% ${deltaLabel}`}
              >
                {computedDelta >= 0 ? (
                  <ArrowUpRight className="size-2.5" aria-hidden />
                ) : (
                  <ArrowDownRight className="size-2.5" aria-hidden />
                )}
                {Math.abs(computedDelta).toLocaleString("en-US", {
                  maximumFractionDigits: 1,
                })}
                %
              </span>
            </TooltipTrigger>
            <TooltipContent>{deltaLabel}</TooltipContent>
          </Tooltip>
        </TooltipProvider>
      )}

      {data && data.length > 1 && (
        // The spark's bottom edge sits on the shared text baseline.
        <Sparkline
          data={data}
          width={sparkWidth}
          height={16}
          color={sparkColor}
          fillArea={false}
          endDot
          formatValue={formatValue}
          static={isStatic}
          className="self-auto"
          aria-label={`Trend of last ${data.length} readings, latest ${formatValue(
            data[data.length - 1],
          )}`}
        />
      )}
    </span>
  );
}