a series as a line the size of a word, as wide as where it is put
npx shadcn add @ag/sparklineAlready installed? Preview an update
npx shadcn add @ag/sparkline --dry-runYour installed source stays unchanged until you update it. How updates work
- New:
Sparkline, a series as a small line beside the number it explains (values,label). As wide as its container and 32 px tall unlessclassNamesays 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 unlessminandmaxsay otherwise;areafills 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 throughformat.
also called trend line, mini chart
shelves data & status, charts
the demo above, verbatim
"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
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>
)
}