135 / Charts
Sparkline
A trend line small enough for a table cell, with the change since the first value and a readout you can move along it.
Active users
2,418
| Route | Today | Trend | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| GET /search | 141 ms |
| ||||||||||||||||||||||||||||||
| POST /checkout | 398 ms |
| ||||||||||||||||||||||||||||||
| GET /orders/:id | 90 ms |
|
"use client"
import { Sparkline } from "mischief-ui/sparkline"
const week = [
"Oct 20",
"Oct 21",
"Oct 22",
"Oct 23",
"Oct 24",
"Oct 25",
"Oct 26",
"Oct 27",
"Oct 28",
"Oct 29",
"Oct 30",
"Oct 31",
"Nov 1",
"Nov 2",
]
const routes = [
{
path: "GET /search",
p95: [182, 176, 190, 201, 188, 170, 164, 171, 168, 159, 162, 150, 148, 141],
},
{
path: "POST /checkout",
p95: [
312,
305,
298,
320,
344,
351,
362,
null,
371,
380,
366,
390,
402,
398,
],
},
{
path: "GET /orders/:id",
p95: [88, 90, 87, 91, 89, 88, 90, 92, 89, 88, 90, 91, 89, 90],
},
]
export function SparklineDemo() {
return (
<div className="grid w-full max-w-[34rem] gap-4">
<div className="bg-card text-card-foreground border-border rounded-xl border p-4">
<p className="text-muted-foreground text-sm">Active users</p>
<div className="mt-1 flex items-end justify-between gap-4">
<p className="text-2xl font-semibold">2,418</p>
<Sparkline
area
className="w-40"
data={[
1980, 2010, 2104, 2050, 1890, 1760, 2120, 2205, 2240, 2190, 2010,
1930, 2330, 2418,
]}
height={36}
label="Daily active users, last 14 days"
labels={week}
/>
</div>
</div>
<div className="border-border min-w-0 overflow-x-auto rounded-xl border">
<table className="w-full min-w-[22rem] text-sm">
<caption className="text-muted-foreground px-4 pt-3 text-left text-xs">
p95 latency in milliseconds, last 14 days. Lower is better.
</caption>
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs">
<th className="px-4 py-2 font-medium" scope="col">
Route
</th>
<th className="px-4 py-2 font-medium" scope="col">
Today
</th>
<th className="px-4 py-2 font-medium" scope="col">
Trend
</th>
</tr>
</thead>
<tbody>
{routes.map((route) => (
<tr
className="border-border border-b last:border-0"
key={route.path}
>
<th
className="px-4 py-3 text-left font-mono text-xs font-normal"
scope="row"
>
{route.path}
</th>
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
{route.p95[route.p95.length - 1]} ms
</td>
<td className="px-4 py-2">
<Sparkline
className="w-32 sm:w-40"
data={route.p95}
format={{ style: "unit", unit: "millisecond" }}
goodDirection="down"
label={`${route.path} p95 latency`}
labels={week}
table
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/sparklineimport { Sparkline } from "mischief-ui/sparkline"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type SparklineProps = Omit< React.HTMLAttributes<HTMLDivElement>, "children"> & { /** One value per step, oldest first. null is a gap in the line. */ data: readonly (number | null)[] /** What the line measures. Names the chart for assistive technology. */ label: stringUsage
export function SignupsCell({ days }: { days: Day[] }) {
return (
<Sparkline
data={days.map((day) => day.signups)}
labels={days.map((day) => day.label)}
label="Signups, last 14 days"
/>
)
}Which change is good news
The badge compares the latest value with the first one and shows the difference as a signed percentage beside an arrow, so the direction is never carried by colour alone. A move in the wrong direction takes the destructive colour. For latency, error counts, and anything else where falling is the good outcome, set goodDirection to down.
<Sparkline
data={p95}
goodDirection="down"
format={{ style: "unit", unit: "millisecond" }}
label="Checkout p95 latency"
/>When the first value is zero there is no meaningful percentage, so the badge is left out. showChange={false} removes it everywhere.
Gaps, flat lines, and negatives
A null in data is a missing day. The line breaks there instead of drawing a slope through a value nobody recorded, and the readout skips it. A flat series draws through the middle, a single value draws only its dot, and a series that crosses zero gets a hairline at zero.
Reading a point
Hovering moves a readout to the nearest point. With the keyboard the chart is one tab stop: the arrow keys step through the points, Home and End jump to the first and the latest. In a long table where every row has a sparkline, scrub={false} takes them out of the tab order.
API
data(number | null)[]One value per step, oldest first. null is a gap.labelstringWhat the line measures. Starts the chart's accessible name.labelsstring[]A name for each step, such as a date, for the readout and the table.areabooleanFills the space under the line with a light wash.showChangebooleanShows the change from the first value. Defaults to true.goodDirection"up" | "down"Which way is good news. Defaults to up.scrubbooleanLets a pointer or the arrow keys read any point. Defaults to true.tablebooleanAdds a visually hidden table of every value.formatIntl.NumberFormatOptionsHow values are written in the readout, the summary, and the table.localestringThe locale for every number.colorstringAny CSS colour. Defaults to var(--chart-1), then var(--primary).heightnumberHeight of the plot in pixels. Defaults to 32. Width follows the element.data-state, data-trend"idle" | "scrubbing", "up" | "down" | "flat"Written to the root for styling.classNamestringClasses for size and placement. The default width is w-36....divPropsHTMLAttributesNative div attributes.Accessibility
The line is an image whose name is written from the data: the low, the high, the latest value, the change since the first value, and how many points are missing. A slider sits over it as a single tab stop, and its value text names the point being read, such as Tue: 1,204, so the readout a pointer sees is also what a screen reader hears. The change badge pairs its colour with an arrow and a sign. table adds a hidden table of every value for anyone who wants the numbers rather than the shape.