Skip to content

Sparkline

An inline trend line that draws on, with a change badge.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/sparkline.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/sparkline/sparkline.tsx
    "use client";
    
    import { useRef, useState, type ComponentProps, type PointerEvent } from "react";
    import { motion, useInView, useReducedMotion } from "motion/react";
    import { TrendingDown, TrendingUp } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { ease } from "@/lib/motion";
    
    export interface SparklineProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Values, oldest first. */
      data: number[];
      /** One label per point (e.g. dates), shown on hover. */
      labels?: string[];
      /** What the line measures; starts the accessible summary. @default "Trend" */
      label?: string;
      /** Width in px. @default 120 */
      width?: number;
      /** Height in px. @default 36 */
      height?: number;
      /** Line color. @default "var(--chart-1)" */
      color?: string;
      /** Fill a soft wash under the line. @default true */
      area?: boolean;
      /** Show the change from the first to the last value as a badge. @default false */
      showChange?: boolean;
      /** Which direction counts as good, for the badge color. @default "up" */
      goodDirection?: "up" | "down";
      /** Formats values on hover and in the accessible summary. */
      formatValue?: (value: number) => string;
      /** Draw the line on the first time it scrolls into view. @default true */
      drawOn?: boolean;
    }
    
    const PAD = 6; // room for the end dot (r 4) and its 2px ring
    const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
    const f = (n: number) => Math.round(n * 100) / 100;
    
    /** Monotone cubic curve: smooth, and never overshoots the data between points. */
    function monotone(points: [number, number][]) {
      const n = points.length;
      if (n < 2) return n ? `M${f(points[0][0])},${f(points[0][1])}` : "";
      const dx: number[] = [];
      const m: number[] = [];
      for (let i = 0; i < n - 1; i++) {
        dx.push(points[i + 1][0] - points[i][0]);
        m.push((points[i + 1][1] - points[i][1]) / dx[i]);
      }
      const t = [m[0]];
      for (let i = 1; i < n - 1; i++) {
        t.push(m[i - 1] * m[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / m[i - 1] + (dx[i] + 2 * dx[i - 1]) / m[i]));
      }
      t.push(m[n - 2]);
      let d = `M${f(points[0][0])},${f(points[0][1])}`;
      for (let i = 0; i < n - 1; i++) {
        const h = dx[i] / 3;
        const [x0, y0] = points[i];
        const [x1, y1] = points[i + 1];
        d += `C${f(x0 + h)},${f(y0 + t[i] * h)},${f(x1 - h)},${f(y1 - t[i + 1] * h)},${f(x1)},${f(y1)}`;
      }
      return d;
    }
    
    export function Sparkline({
      data,
      labels,
      label = "Trend",
      width = 120,
      height = 36,
      color = "var(--chart-1)",
      area = true,
      showChange = false,
      goodDirection = "up",
      formatValue = (v) => number.format(v),
      drawOn = true,
      className,
      ...props
    }: SparklineProps) {
      const reduceMotion = useReducedMotion();
      const ref = useRef<HTMLDivElement>(null);
      const inView = useInView(ref, { once: true, amount: 0.6 });
      const [active, setActive] = useState<number | null>(null);
    
      const min = Math.min(...data);
      const max = Math.max(...data);
      const span = max - min || 1;
      const step = data.length > 1 ? (width - PAD * 2) / (data.length - 1) : 0;
      const points = data.map((v, i): [number, number] => [PAD + i * step, PAD + (1 - (v - min) / span) * (height - PAD * 2)]);
      const line = monotone(points);
      const last = points[points.length - 1];
    
      const first = data[0] ?? 0;
      const end = data[data.length - 1] ?? 0;
      const change = first === 0 ? 0 : ((end - first) / Math.abs(first)) * 100;
      const good = change === 0 ? null : (change > 0) === (goodDirection === "up");
      const changeText = `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
      const summary = data.length
        ? `${label}: ${formatValue(first)} to ${formatValue(end)}${first ? ` (${changeText})` : ""}, low ${formatValue(min)}, high ${formatValue(max)}`
        : `${label}: no data`;
    
      const shown = !drawOn || reduceMotion || inView;
    
      const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
        if (!data.length) return;
        const rect = event.currentTarget.getBoundingClientRect();
        const x = ((event.clientX - rect.left) / rect.width) * width;
        setActive(Math.min(data.length - 1, Math.max(0, Math.round((x - PAD) / (step || 1)))));
      };
    
      const point = active === null ? null : points[active];
    
      return (
        <div className={cn("inline-flex items-center gap-2", className)} {...props}>
          <div
            ref={ref}
            role="img"
            aria-label={summary}
            className="relative shrink-0"
            style={{ width, height }}
            onPointerMove={onPointerMove}
            onPointerLeave={() => setActive(null)}
          >
            {/* Draw-on is a left-to-right clip-path reveal of line and wash together. */}
            <motion.div
              className="absolute inset-0"
              initial={drawOn && !reduceMotion ? { clipPath: `inset(-2px ${width + 2}px -2px -2px)` } : false}
              animate={shown ? { clipPath: "inset(-2px -8px -2px -2px)" } : undefined}
              transition={{ duration: 0.9, ease: ease.out }}
            >
              <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible" aria-hidden="true">
                {area && points.length > 1 ? (
                  <path d={`${line}L${f(last[0])},${height}L${f(points[0][0])},${height}Z`} fill={color} fillOpacity={0.1} />
                ) : null}
                <path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </motion.div>
            {last ? (
              <svg width={width} height={height} className="pointer-events-none absolute inset-0 overflow-visible" aria-hidden="true">
                <motion.circle
                  cx={last[0]}
                  cy={last[1]}
                  r={4}
                  fill={color}
                  stroke="var(--background)"
                  strokeWidth={2}
                  initial={drawOn && !reduceMotion ? { scale: 0 } : false}
                  animate={shown ? { scale: active === null ? 1 : 0.6 } : undefined}
                  transition={{ type: "spring", stiffness: 500, damping: 26, delay: active === null && drawOn ? 0.75 : 0 }}
                />
                {point ? <circle cx={point[0]} cy={point[1]} r={4} fill={color} stroke="var(--background)" strokeWidth={2} /> : null}
              </svg>
            ) : null}
            {point && active !== null ? (
              <div
                aria-hidden="true"
                className="pointer-events-none absolute bottom-full z-10 mb-1.5 -translate-x-1/2 rounded-md bg-foreground px-1.5 py-0.5 text-[11px] whitespace-nowrap text-background shadow-md"
                style={{ left: Math.min(Math.max(point[0], 24), width - 24) }}
              >
                <span className="font-semibold tabular-nums">{formatValue(data[active])}</span>
                {labels?.[active] ? <span className="ml-1 text-background/65">{labels[active]}</span> : null}
              </div>
            ) : null}
          </div>
          {showChange && data.length > 1 ? (
            <span
              className={cn(
                "inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
                good === null ? "bg-muted text-foreground/65" : good ? "bg-success/10 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" : "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]",
              )}
            >
              {change >= 0 ? <TrendingUp className="size-3" aria-hidden="true" /> : <TrendingDown className="size-3" aria-hidden="true" />}
              <span className="sr-only">{change >= 0 ? "Up" : "Down"} </span>
              {changeText.replace("-", "−")}
            </span>
          ) : null}
        </div>
      );
    }

Usage

import { Sparkline } from "@/components/ui/sparkline/sparkline";

export function Row() {
  return <Sparkline data={[142, 148, 151, 158, 163, 170]} label="Northwind, 6 weeks" showChange />;
}

API reference

PropTypeDefaultDescription
data*number[]–Values, oldest first.
labelsstring[]–One label per point (e.g. dates), shown on hover.
labelstring"Trend"What the line measures; starts the accessible summary.
widthnumber120Width in px.
heightnumber36Height in px.
colorstring"var(--chart-1)"Line color.
areabooleantrueFill a soft wash under the line.
showChangebooleanfalseShow the change from the first to the last value as a badge.
goodDirection"up" | "down""up"Which direction counts as good, for the badge color.
formatValue(value: number) => string–Formats values on hover and in the accessible summary.
drawOnbooleantrueDraw the line on the first time it scrolls into view.

Also accepts every native <div> attribute, including ref and className.

  • Follows the chart spec: a 2px line, a 10% wash, and an end dot with a 2px ring in the surface color.
  • The draw-on is a clip-path reveal, and hovering shows the nearest point's value (and label, if you pass labels).
  • Fixed size by design (width and height props); for a full-width trend use the Area Chart.

Accessibility

  • Exposed as an image whose name summarises the series: first and last value, the change, and the low and high.
  • The change badge pairs color with an arrow icon and a signed number, so direction never depends on color alone; its color follows goodDirection (for latency or errors, down is good).
  • With reduced motion enabled, the line appears without drawing on.