← ui

sparkline

ui

a series as a line the size of a word, as wide as where it is put

npx shadcn add @ag/sparkline
Already installed? Preview an update
npx shadcn add @ag/sparkline --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • New: Sparkline, a series as a small line beside the number it explains (values, label). As wide as its container and 32 px tall unless className says otherwise; the stroke stays 1.5 px and the last value's dot round at any size, with nothing measured, so it is right on the first paint and renders in a server component. The scale takes in zero unless min and max say otherwise; area fills to zero. Empty draws nothing, one value is its dot alone. A screen reader hears the label and the series' first, last, lowest and highest values through format.
Updated 2026-10-12 · release notes

also called trend line, mini chart

shelves data & status, charts

the demo above, verbatim

sparkline.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/registry/base-nova/ui/resizable"
import { Sparkline } from "@/registry/base-nova/ui/sparkline"

// #region series
/** Thirty days of a site's numbers, oldest first. */
const VISITS = [
  1210, 1342, 1288, 1404, 1520, 1187, 1033, 1460, 1588, 1612, 1549, 1702, 1390,
  1201, 1655, 1790, 1832, 1760, 1904, 1611, 1480, 1960, 2044, 2112, 1987, 2230,
  1906, 1784, 2311, 2398,
]
const SIGNUPS = [
  14, 12, 18, 11, 9, 7, 16, 21, 19, 17, 13, 10, 8, 15, 22, 25, 18, 16, 12, 9,
  11, 14, 13, 17, 12, 8, 6, 10, 9, 7,
]
const ERRORS = [
  0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 14, 3, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1,
  0, 0, 0, 0,
]
/** A p95 latency in ms: it lives between 180 and 240, far from zero. */
const LATENCY = [
  212, 205, 219, 224, 208, 199, 214, 231, 226, 218, 204, 196, 201, 209, 222,
  236, 229, 215, 207, 198, 203, 211, 219, 227, 233, 224, 213, 206, 201, 197,
]
/** Profit by week, in thousands: it crosses zero. */
const PROFIT = [-12, -8, -9, -3, 2, -1, 4, 9, 7, 12, 15, 11]
// #endregion

// #region beside
function Beside() {
  const rows = [
    { name: "visits", value: "2,398", values: VISITS },
    { name: "signups", value: "7", values: SIGNUPS },
    { name: "errors", value: "0", values: ERRORS },
  ]
  return (
    <dl className="m-0 grid w-72 grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-4 gap-y-3 text-[13px]">
      {rows.map((r) => (
        <div key={r.name} className="col-span-3 grid grid-cols-subgrid">
          <dt className="text-current/60">{r.name}</dt>
          <dd className="m-0 text-right font-mono tabular-nums">{r.value}</dd>
          <dd className="m-0">
            <Sparkline
              values={r.values}
              label={`${r.name}, last 30 days`}
              className="h-6"
            />
          </dd>
        </div>
      ))}
    </dl>
  )
}
// #endregion

export default function SparklineDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="beside"
        glance
        title="beside the number it explains"
        with="series beside"
      >
        <Beside />
      </Sample>
      <Sample
        name="width"
        title="as wide as its container"
        description="drag the handle: the line is drawn at the element's own size, nothing measured, the stroke 1.5 px and the dot round at any width."
        with="series"
      >
        <div className="overflow-hidden rounded-xl ring-1 ring-foreground/10">
          <ResizablePanelGroup>
            <ResizablePanel defaultSize={0.3} minSize={0.08}>
              <div className="p-4">
                <Sparkline values={VISITS} label="visits, last 30 days" area />
              </div>
            </ResizablePanel>
            <ResizableHandle withHandle aria-label="the sparkline's width" />
            <ResizablePanel>
              <div className="p-4">
                <Sparkline
                  values={VISITS}
                  label="visits, last 30 days"
                  className="h-16"
                />
              </div>
            </ResizablePanel>
          </ResizablePanelGroup>
        </div>
      </Sample>
      <Sample
        name="tones"
        title="in the ink of where it stands"
        description="currentColor: a text colour on it, or on what holds it, colours it."
        with="series"
      >
        <div className="grid w-72 gap-4">
          <Sparkline
            values={VISITS}
            label="visits"
            area
            className="text-tone-ok"
          />
          <Sparkline
            values={ERRORS}
            label="errors"
            area
            className="text-tone-bad"
          />
          <Sparkline
            values={SIGNUPS}
            label="signups"
            className="text-current/50"
          />
        </div>
      </Sample>
      <Sample
        name="scale"
        title="the scale takes in zero"
        description="left, the default: latency from zero, a steady line. right, min 180 and max 240: the same values, looked at closely. a profit that crosses zero fills to zero, not to its floor."
        with="series"
      >
        <div className="grid gap-6 sm:grid-cols-3">
          <Sparkline values={LATENCY} label="p95 latency, ms" area />
          <Sparkline
            values={LATENCY}
            label="p95 latency, ms"
            min={180}
            max={240}
            area
          />
          <Sparkline values={PROFIT} label="profit by week, thousands" area />
        </div>
      </Sample>
      <Sample
        name="edges"
        title="empty, one value, all zero"
        description="it draws only what is there, each in its box (shaded here): nothing for no values, the dot alone for one (the latest, at the end), the floor for a series of zeros, a steady 5 at the top of a scale from zero."
      >
        <div className="grid w-72 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-4 text-[13px] text-current/60">
          <span>none</span>
          <Sparkline values={[]} label="refunds" className="h-8 bg-current/5" />
          <span>one</span>
          <Sparkline
            values={[42]}
            label="first day"
            className="h-8 bg-current/5"
          />
          <span>zeros</span>
          <Sparkline
            values={[0, 0, 0, 0, 0, 0]}
            label="outages"
            className="h-8 bg-current/5"
          />
          <span>steady</span>
          <Sparkline
            values={[5, 5, 5, 5, 5, 5]}
            label="seats"
            className="h-8 bg-current/5"
          />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

sparkline.tsx
import type * as React from "react"
import { cn } from "@/lib/utils"

// A SERIES AS A LINE THE SIZE OF A WORD: the shape of a window at a glance, beside the
// number it explains (a stat's, a table cell's). No axes, no labels, no hover: what a
// reader needs from it is up, down, steady or spiking.
//
//   <Sparkline values={visits} label="visits, last 30 days" />
//   <Sparkline values={revenue} label="revenue" area className="h-10 w-32 text-tone-ok" />
//
// SIZED BY ITS CONTAINER, both ways: as wide as where it is put (`w-full`) and 32 px
// tall unless `className` says otherwise. Nothing is measured and no width is
// guessed: the line is drawn in a 1 by 1 box the browser stretches to the element's
// own size (`preserveAspectRatio="none"`), the stroke kept 1.5 px whatever the
// stretch (`vector-effect: non-scaling-stroke`), and the last value's dot is a round
// element placed at its point in percent, so it stays round. A sparkline is right at
// any width on the first paint, server-rendered, with nothing to hydrate.
//
// THE SCALE: from `min` to `max`, by default the values' own range widened to take in
// zero, so a count that went from 40 to 44 reads as the small change it is, not as a
// cliff. Pass `min` (and `max`) to look closely at a series far from zero (a latency
// around 200 ms); a value outside them screams. The area, when drawn, fills down to
// zero, or to the floor when zero is outside the scale.
//
// EMPTY AND ONE VALUE say only what is there: an empty series draws nothing, one value
// is its dot alone at the right end (the latest), never a line made up to join it to
// nothing. In the ink of where it stands (`currentColor`).
//
// A screen reader hears `label` and what the line shows: how many values, the first
// and the last, the lowest and the highest, each through `format`.
//
// No "use client": it renders in a server component as it is, `format` included.

const plain = new Intl.NumberFormat("en-US")

export type SparklineProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The series, oldest first. */
  values: readonly number[]
  /** What the series is, for a screen reader ("visits, last 30 days"). */
  label: string
  /** The area under the line, filled faintly. Default off. */
  area?: boolean
  /** The last value marked with a dot, so "now" reads without an axis. Default on. */
  dot?: boolean
  /** The scale's floor. Default the lowest value, or zero when lower. */
  min?: number
  /** The scale's top. Default the highest value, or zero when higher. */
  max?: number
  /** How a value is said to a screen reader. Default grouped digits ("48,210"). */
  format?: (value: number) => string
}

/** What the line shows, in words. */
function described(
  label: string,
  values: readonly number[],
  format: (value: number) => string,
): string {
  const first = values[0]
  const last = values.at(-1)
  if (first === undefined || last === undefined) return `${label}: no values`
  if (values.length === 1) return `${label}: one value, ${format(first)}`
  return `${label}: ${values.length} values, from ${format(first)} to ${format(last)}, lowest ${format(Math.min(...values))}, highest ${format(Math.max(...values))}`
}

export function Sparkline({
  values,
  label,
  area = false,
  dot = true,
  min,
  max,
  format = plain.format,
  className,
  ...props
}: SparklineProps) {
  for (const v of values)
    if (!Number.isFinite(v))
      throw new Error(`Sparkline "${label}": ${v} is no value`)
  const floor = min ?? Math.min(0, ...values)
  const top = max ?? Math.max(0, ...values)
  if (min !== undefined && max !== undefined && !(max > min))
    throw new Error(`Sparkline "${label}": max ${max} is not above min ${min}`)
  for (const v of values)
    if (v < floor || v > top)
      throw new Error(
        `Sparkline "${label}": ${v} is outside the scale ${floor} to ${top}`,
      )
  // Only an all-zero series has no range: its line lies on the floor.
  const range = top - floor || 1
  const n = values.length
  /** Where a value falls, 0 at the top and 1 at the floor. */
  const y = (v: number) => (top - v) / range
  /** Where the i-th value falls across; one value is the latest, at the right end. */
  const x = (i: number) => (n === 1 ? 1 : i / (n - 1))
  const points = values
    .map((v, i) => `${x(i).toFixed(4)},${y(v).toFixed(4)}`)
    .join(" ")
  const base = y(Math.min(Math.max(0, floor), top)).toFixed(4)
  const last = values.at(-1)

  return (
    <div
      role="img"
      aria-label={described(label, values, format)}
      data-slot="sparkline"
      className={cn("relative h-8 w-full", className)}
      {...props}
    >
      {n > 1 && (
        <svg
          aria-hidden
          viewBox="0 0 1 1"
          preserveAspectRatio="none"
          className="absolute inset-0 size-full overflow-visible"
        >
          {area && (
            <polygon
              data-slot="sparkline-area"
              points={`0,${base} ${points} 1,${base}`}
              className="fill-current/12"
            />
          )}
          <polyline
            data-slot="sparkline-line"
            points={points}
            fill="none"
            stroke="currentColor"
            strokeWidth={1.5}
            strokeLinejoin="round"
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
          />
        </svg>
      )}
      {dot && last !== undefined && (
        <span
          aria-hidden
          data-slot="sparkline-dot"
          className="absolute top-(--ag-sparkline-y) left-(--ag-sparkline-x) size-1.5 -translate-1/2 rounded-full bg-current"
          style={
            {
              "--ag-sparkline-x": `${x(n - 1) * 100}%`,
              "--ag-sparkline-y": `${y(last) * 100}%`,
            } as React.CSSProperties
          }
        />
      )}
    </div>
  )
}