Charts

Sparkline

An inline line spark that draws in on view, with gradient area fill, end-point dot, and up/down tinting.

Install

npx shadcn@latest add @paragon/sparkline

sparkline.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const TREND_COLORS = {
  up: "oklch(0.66 0.14 160)",
  down: "oklch(0.62 0.18 25)",
  neutral: "currentColor",
} as const;

export interface SparklineProps
  extends Omit<React.ComponentProps<"svg">, "children"> {
  /** Series values, oldest first. */
  data: number[];
  width?: number;
  height?: number;
  strokeWidth?: number;
  /** Tints the line — green for up, red for down, currentColor otherwise. */
  trend?: "up" | "down" | "neutral";
  /** Explicit stroke color. Overrides `trend`. */
  color?: string;
  /** Gradient area fill under the line, fading to transparent. */
  fillArea?: boolean;
  /** Dot marking the most recent point. */
  endDot?: boolean;
  /** Hollow markers on the min and max points. */
  showExtremes?: boolean;
  /** Optional labels for the x-axis, aligned to each value (used in tooltip). */
  labels?: string[];
  /** Formats the value shown in the hover tooltip. */
  formatValue?: (value: number) => string;
  /** Renders the final state immediately, no draw-in. */
  static?: boolean;
}

/**
 * An inline line spark for metric rows and stat cards. The stroke draws in
 * once when scrolled into view (stroke-dashoffset via a normalized
 * pathLength), then the area fill and end dot fade up. Hovering — or focusing
 * with the keyboard — snaps a crosshair to the nearest point and opens a
 * value tooltip; every coordinate is computed from a linear scale, never
 * eyeballed. Under prefers-reduced-motion the final state renders immediately.
 */
export function Sparkline({
  data,
  width = 96,
  height = 32,
  strokeWidth = 1.5,
  trend = "neutral",
  color,
  fillArea = true,
  endDot = true,
  showExtremes = false,
  labels,
  formatValue = (v) => v.toLocaleString("en-US"),
  static: isStatic = false,
  className,
  "aria-label": ariaLabel,
  ...props
}: SparklineProps) {
  const ref = React.useRef<SVGSVGElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const gradientId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");

  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const stroke = color ?? TREND_COLORS[trend];
  const hasData = data.length > 0;
  const values = data.length === 1 ? [data[0], data[0]] : data;
  const n = values.length;

  const [active, setActive] = React.useState<number | null>(null);

  // --- Scales (every coordinate computed, never eyeballed) -----------------
  const pad = Math.max(strokeWidth, endDot || showExtremes ? 3 : 1) + 0.5;
  const min = hasData ? Math.min(...values) : 0;
  const max = hasData ? Math.max(...values) : 1;
  const range = max - min || 1;

  const x = React.useCallback(
    (i: number) => pad + (n === 1 ? 0 : (i / (n - 1)) * (width - pad * 2)),
    [pad, n, width],
  );
  const y = React.useCallback(
    (v: number) => height - pad - ((v - min) / range) * (height - pad * 2),
    [height, pad, min, range],
  );

  const points = React.useMemo(
    () => values.map((v, i) => ({ x: x(i), y: y(v), v, i })),
    [values, x, y],
  );

  const line = points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y}`).join("");
  const area = `${line}L${x(n - 1)} ${height}L${x(0)} ${height}Z`;
  const last = hasData ? values[n - 1] : 0;

  // Extremes on the ORIGINAL series (skip the duplicated single-point case).
  const minIdx = React.useMemo(
    () => (hasData ? values.indexOf(min) : -1),
    [hasData, values, min],
  );
  const maxIdx = React.useMemo(
    () => (hasData ? values.indexOf(max) : -1),
    [hasData, values, max],
  );

  const pickNearest = React.useCallback(
    (clientX: number) => {
      const svg = ref.current;
      if (!svg) return;
      const rect = svg.getBoundingClientRect();
      const localX = ((clientX - rect.left) / rect.width) * width;
      let best = 0;
      let bestDist = Infinity;
      for (const p of points) {
        const d = Math.abs(p.x - localX);
        if (d < bestDist) {
          bestDist = d;
          best = p.i;
        }
      }
      setActive(best);
    },
    [points, width],
  );

  const activePoint = active !== null ? points[active] : null;
  const activeLabel =
    active !== null && labels ? labels[Math.min(active, labels.length - 1)] : null;

  if (!hasData) {
    return (
      <div
        role="img"
        aria-label="No data"
        className={cn(
          "flex shrink-0 items-center justify-center rounded-md bg-current/[0.04] text-[10px] text-muted-foreground",
          className,
        )}
        style={{ width, height }}
      >
        No data
      </div>
    );
  }

  return (
    <span
      className={cn("relative inline-flex shrink-0", className)}
      style={{ width, height }}
    >
      <svg
        ref={ref}
        role="img"
        tabIndex={0}
        aria-label={
          ariaLabel ??
          `Sparkline of ${data.length} values, latest ${last.toLocaleString("en-US")}`
        }
        viewBox={`0 0 ${width} ${height}`}
        width={width}
        height={height}
        data-slot="sparkline"
        className="overflow-visible outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background rounded-[3px]"
        onPointerMove={(e) => pickNearest(e.clientX)}
        onPointerLeave={() => setActive(null)}
        onFocus={() => setActive(n - 1)}
        onBlur={() => setActive(null)}
        onKeyDown={(e) => {
          if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
            e.preventDefault();
            setActive((prev) => {
              const cur = prev ?? n - 1;
              const next = e.key === "ArrowRight" ? cur + 1 : cur - 1;
              return Math.max(0, Math.min(n - 1, next));
            });
          }
        }}
        {...props}
      >
        <defs>
          <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor={stroke} stopOpacity={0.2} />
            <stop offset="100%" stopColor={stroke} stopOpacity={0} />
          </linearGradient>
        </defs>

        {fillArea && (
          <path
            d={area}
            fill={`url(#${gradientId})`}
            aria-hidden
            style={{
              opacity: drawn ? 1 : 0,
              transition: animate
                ? "opacity 300ms var(--ease-out) 250ms"
                : undefined,
            }}
          />
        )}

        <path
          d={line}
          fill="none"
          stroke={stroke}
          strokeWidth={strokeWidth}
          strokeLinecap="round"
          strokeLinejoin="round"
          vectorEffect="non-scaling-stroke"
          pathLength={1}
          strokeDasharray={1}
          style={{
            strokeDashoffset: drawn ? 0 : 1,
            transition: animate
              ? "stroke-dashoffset 600ms var(--ease-out)"
              : undefined,
          }}
        />

        {/* Min / max hollow markers */}
        {showExtremes &&
          [minIdx, maxIdx].map((idx, k) =>
            idx >= 0 ? (
              <circle
                key={k}
                cx={x(idx)}
                cy={y(values[idx])}
                r={2.25}
                fill="var(--color-card)"
                stroke={stroke}
                strokeWidth={1.25}
                vectorEffect="non-scaling-stroke"
                aria-hidden
                style={{
                  opacity: drawn ? 0.9 : 0,
                  transition: animate
                    ? "opacity 200ms var(--ease-out) 450ms"
                    : undefined,
                }}
              />
            ) : null,
          )}

        {endDot && (
          <circle
            cx={x(n - 1)}
            cy={y(last)}
            r={Math.max(strokeWidth * 1.25, 2)}
            fill={stroke}
            style={{
              opacity: drawn ? 1 : 0,
              transition: animate
                ? "opacity 200ms var(--ease-out) 450ms"
                : undefined,
            }}
          />
        )}

        {/* Hover crosshair + active point */}
        {activePoint && (
          <g aria-hidden pointerEvents="none">
            <line
              x1={activePoint.x}
              y1={0}
              x2={activePoint.x}
              y2={height}
              stroke={stroke}
              strokeWidth={1}
              strokeOpacity={0.25}
              vectorEffect="non-scaling-stroke"
            />
            <circle
              cx={activePoint.x}
              cy={activePoint.y}
              r={2.75}
              fill="var(--color-card)"
              stroke={stroke}
              strokeWidth={1.5}
              vectorEffect="non-scaling-stroke"
            />
          </g>
        )}
      </svg>

      {/* Cursor-following tooltip */}
      {activePoint && (
        <span
          role="status"
          className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground shadow-overlay tabular-nums"
          style={{
            left: `${(activePoint.x / width) * 100}%`,
            top: -6,
          }}
        >
          {activeLabel ? (
            <span className="mr-1.5 opacity-70">{activeLabel}</span>
          ) : null}
          {formatValue(activePoint.v)}
        </span>
      )}
    </span>
  );
}