Sparkline
A compact SVG history for a grid cell, with direction, gaps, and an optional crosshair.
import { Sparkline } from "@/components/ui/sparkline"
const prices = [99.5, 99.52, 99.51, 99.56, 99.54, 99.55]
export default function SparklineDemo() {
return <Sparkline values={prices} label="ABC, 10:00–10:05" width={160} height={40} format={(value) => value.toFixed(2)} />
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/sparkline#v2.0.0
npx shadcn@latest add tradecn/ui/sparkline#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/sparkline#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/sparkline#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Copy the files into your project:
components/ui/ sparkline.tsx import { cn } from "cn" import { useCallback, useLayoutEffect, useMemo, useState, type ComponentProps, type KeyboardEvent, type PointerEvent, type Ref } from "react" import { directionOf, type Direction } from "@/hooks/use-flash" import { buildSparklineGeometry, nearestPointIndex, observeSize, type Size } from "@/lib/sparkline-geometry" // A line small enough for a grid cell that still says which way, how far, and between what. // // The color is the direction of the last reading against the first, or against a baseline such as a // previous close, and the same direction is in `data-direction` and in the words a screen reader // gets, because color is never the only channel. Give it a width and a height and it measures // nothing; leave them out and it fills its box, through one ResizeObserver shared by every // sparkline on the page. export type SparklineDirection = Direction | "auto" | "none" export interface SparklineProps extends Omit<ComponentProps<"div">, "children"> { /** Readings in order. A null, undefined, or NaN is a gap: the line breaks and the later points stay where they belong. */ values: readonly (number | null | undefined)[] /** What this is, for a screen reader: "ZN, last 30 minutes". The direction, last, low, and high are added. */ label: string /** `auto` compares the last reading with the baseline, or with the first reading. `none` draws in the foreground color. Default `auto`. */ direction?: SparklineDirection /** A level to compare against and to draw: a previous close. */ baseline?: number /** Both given, the size is fixed and nothing is observed. That is the cheap way in a grid column. */ width?: number height?: number /** Tint under the line. Default true. */ area?: boolean /** A crosshair you can move with the pointer and with the arrow keys. Default false, so a cell stays inert. */ interactive?: boolean format?: (value: number) => string /** Names a reading in the crosshair's readout, by its index in `values`: a time. */ pointLabel?: (index: number) => string } type Tone = Direction | "none" const STROKE: Record<Tone, string> = { up: "stroke-up", down: "stroke-down", flat: "stroke-flat", none: "stroke-foreground/70" } const AREA: Record<Tone, string> = { up: "fill-up-soft", down: "fill-down-soft", flat: "fill-flat-soft", none: "fill-foreground/10" } const MARKER: Record<Tone, string> = { up: "fill-up", down: "fill-down", flat: "fill-flat", none: "fill-foreground" } const WORD: Record<Tone, string> = { up: "up", down: "down", flat: "flat", none: "" } const defaultFormat = (value: number) => String(value) function assignRef(ref: Ref<HTMLDivElement> | undefined, node: HTMLDivElement | null) { if (typeof ref === "function") return ref(node) if (ref) ref.current = node } export function Sparkline({ values, label, direction = "auto", baseline, width, height, area = true, interactive = false, format = defaultFormat, pointLabel, className, style, ref, ...props }: SparklineProps) { const fixed = width !== undefined && height !== undefined const [element, setElement] = useState<HTMLDivElement | null>(null) // The caller's ref rides along with the measurement target instead of being replaced by it. const rootRef = useCallback((node: HTMLDivElement | null) => { setElement(node) const cleanup = assignRef(ref, node) return () => { if (typeof cleanup === "function") cleanup() else assignRef(ref, null) } }, [ref]) const [measured, setMeasured] = useState<Size | null>(null) const [active, setActive] = useState<number | null>(null) useLayoutEffect(() => { if (fixed || !element) return return observeSize(element, setMeasured) }, [fixed, element]) // Two numbers, not an object: at a fixed size an object would be new on every render and the geometry with it. const w = fixed ? width : measured?.width const h = fixed ? height : measured?.height const geometry = useMemo(() => (w === undefined || h === undefined ? null : buildSparklineGeometry(values, w, h, { baseline })), [values, w, h, baseline]) const points = geometry?.points ?? [] // The series can get shorter under a crosshair that was parked at its end. const at = active === null || !points.length ? null : points[Math.min(active, points.length - 1)]! const describe = (index: number, value: number) => `${pointLabel ? `${pointLabel(index)} ` : ""}${format(value)}` // Scanned once per render instead of taken from the geometry, so the words are right before the first measurement too. let low = Infinity let high = -Infinity let earliest: number | null = null let latest: number | null = null for (const v of values) { if (typeof v !== "number" || !Number.isFinite(v)) continue if (earliest === null) earliest = v latest = v if (v < low) low = v if (v > high) high = v } // Direction reads the data the same way, never the drawn geometry, which is empty before // the first measurement and in server output. const tone: Tone = direction === "auto" ? (earliest !== null && latest !== null ? directionOf(baseline ?? earliest, latest) : "flat") : direction const summary = latest === null ? `${label}: no data` : `${label}: ${WORD[tone] ? `${WORD[tone]}, ` : ""}last ${format(latest)}, low ${format(low)}, high ${format(high)}` function move(to: number) { if (points.length) setActive(Math.max(0, Math.min(points.length - 1, to))) } function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { props.onKeyDown?.(event) // Modified chords belong to listeners above: Ctrl, Cmd, or Alt with an arrow is a hotkey, not a step. if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || !points.length) return const from = active === null ? points.length - 1 : Math.min(active, points.length - 1) const to = { ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: from + 1, ArrowUp: from + 1, PageDown: from - 10, PageUp: from + 10, Home: 0, End: points.length - 1 }[event.key] if (to === undefined && event.key !== "Escape") return // Claimed, so a grid or a hotkey dispatcher further out leaves the key alone. event.preventDefault() if (to === undefined) setActive(null) else move(to) } function onPointerMove(event: PointerEvent<HTMLDivElement>) { props.onPointerMove?.(event) if (w === undefined) return const box = event.currentTarget.getBoundingClientRect() if (box.width > 0) move(nearestPointIndex(points, ((event.clientX - box.left) / box.width) * w)) } const live = interactive && points.length > 0 // A chart that stops being live, with no readings or no longer interactive, forgets its crosshair: readings that // come back later must not draw one that no pointer or focus picked. if (!live && active !== null) setActive(null) return ( <div role={live ? "slider" : "img"} aria-label={live ? label : summary} {...(live ? { tabIndex: 0, "aria-orientation": "horizontal" as const, "aria-valuemin": 0, "aria-valuemax": points.length - 1, "aria-valuenow": at ? points.indexOf(at) : points.length - 1, "aria-valuetext": at ? describe(at.index, at.value) : summary } : {})} {...props} ref={rootRef} data-slot="tradecn-sparkline" data-direction={tone} data-empty={points.length === 0 && latest === null ? "" : undefined} onKeyDown={live ? onKeyDown : props.onKeyDown} onPointerMove={live ? onPointerMove : props.onPointerMove} onPointerLeave={(event) => { props.onPointerLeave?.(event) // The chart's own root: a popout runs in the opener's JavaScript, where the global // document never holds the popout's focus, and in a shadow root the document's is the host. if (live && (event.currentTarget.getRootNode() as Document | ShadowRoot).activeElement !== event.currentTarget) setActive(null) }} onFocus={(event) => { props.onFocus?.(event) if (live && active === null) setActive(points.length - 1) }} onBlur={(event) => { props.onBlur?.(event) if (live) setActive(null) }} className={cn("relative inline-block h-6 w-24 shrink-0 align-middle outline-none lining-nums tabular-nums", live && "cursor-crosshair rounded-sm focus-visible:ring-2 focus-visible:ring-ring/50", className)} style={fixed ? { ...style, width, height } : style} > {geometry && w !== undefined && h !== undefined && ( <svg aria-hidden width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="block overflow-visible" fill="none"> {geometry.baselineY !== null && <line x1={0} x2={w} y1={geometry.baselineY} y2={geometry.baselineY} className="stroke-border" strokeWidth={1} strokeDasharray="2 2" />} {area && geometry.area && <path d={geometry.area} className={AREA[tone]} stroke="none" />} <path d={geometry.line} className={STROKE[tone]} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> {live && at && ( <> <line x1={at.x} x2={at.x} y1={0} y2={h} className="stroke-muted-foreground/60" strokeWidth={1} /> <circle cx={at.x} cy={at.y} r={2.5} className={MARKER[tone]} /> </> )} </svg> )} {live && at && w !== undefined && ( <span data-sparkline-readout="" data-numeric="" className={cn("pointer-events-none absolute -top-4 rounded-sm bg-popover px-1 text-xs leading-4 whitespace-nowrap text-popover-foreground lining-nums tabular-nums shadow-sm", at.x > w / 2 ? "right-0" : "left-0")}> {describe(at.index, at.value)} </span> )} </div> ) }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) }lib/sparkline-geometry.ts // Sparkline geometry as a pure function, and one ResizeObserver shared by every sparkline on the page. // // A point keeps the x of its own index. A reading that is missing (null, NaN) leaves a gap and the // line breaks there; it does not pull the later points left, because on a time axis that would put // the afternoon where the morning was. A flat series is drawn through the middle of the box, since // the bottom edge would say "at the low" about a series that has no low. export interface SparklinePoint { /** Position in the values that were passed in, gaps counted. */ index: number value: number x: number y: number } export interface SparklineGeometry { /** SVG path data. One subpath per unbroken run of readings. */ line: string /** The same runs, each closed down to the floor of the plot. */ area: string /** The readings that are finite numbers, in order. */ points: SparklinePoint[] min: number max: number /** Where the baseline falls, when one was given. */ baselineY: number | null } export interface SparklineOptions { /** Room inside the box so a 1.5 px stroke at the high or the low is not clipped. Default 2. */ padding?: number /** A level to compare against, a previous close. It joins the scale so it is always on the plot. */ baseline?: number } const EMPTY: SparklineGeometry = { line: "", area: "", points: [], min: 0, max: 0, baselineY: null } const round = (n: number) => Math.round(n * 100) / 100 export function buildSparklineGeometry(values: readonly (number | null | undefined)[], width: number, height: number, options: SparklineOptions = {}): SparklineGeometry { const padding = Math.max(0, options.padding ?? 2) const plotWidth = Math.max(1, width - padding * 2) const plotHeight = Math.max(1, height - padding * 2) const baseline = typeof options.baseline === "number" && Number.isFinite(options.baseline) ? options.baseline : null // A loop, not Math.min(...values): a spread of a long history overflows the stack. let min = Infinity let max = -Infinity let count = 0 for (const v of values) { if (typeof v !== "number" || !Number.isFinite(v)) continue count++ if (v < min) min = v if (v > max) max = v } if (!count) return EMPTY const low = baseline === null ? min : Math.min(min, baseline) const high = baseline === null ? max : Math.max(max, baseline) const range = high - low const yOf = (v: number) => round(range === 0 ? padding + plotHeight / 2 : padding + plotHeight - ((v - low) / range) * plotHeight) const xOf = (i: number) => round(values.length === 1 ? padding + plotWidth / 2 : padding + (i / (values.length - 1)) * plotWidth) const floor = round(padding + plotHeight) const points: SparklinePoint[] = [] const line: string[] = [] const area: string[] = [] let run: SparklinePoint[] = [] const endRun = () => { if (!run.length) return const d = run.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y}`).join(" ") // A run of one has no length. `h0` gives the stroke's round cap something to draw, so it shows as a dot. line.push(run.length === 1 ? `${d} h0` : d) if (run.length > 1) area.push(`${d} L${run[run.length - 1]!.x} ${floor} L${run[0]!.x} ${floor} Z`) run = [] } for (let i = 0; i < values.length; i++) { const v = values[i] if (typeof v !== "number" || !Number.isFinite(v)) { endRun() continue } const point = { index: i, value: v, x: xOf(i), y: yOf(v) } points.push(point) run.push(point) } endRun() return { line: line.join(" "), area: area.join(" "), points, min, max, baselineY: baseline === null ? null : yOf(baseline) } } /** The position in `points` of the point nearest to `x`. -1 when there are none. */ export function nearestPointIndex(points: readonly SparklinePoint[], x: number): number { if (!points.length) return -1 let lo = 0 let hi = points.length - 1 while (hi - lo > 1) { const mid = (lo + hi) >> 1 if (points[mid]!.x <= x) lo = mid else hi = mid } return Math.abs(points[lo]!.x - x) <= Math.abs(points[hi]!.x - x) ? lo : hi } export interface Size { width: number height: number } let observer: ResizeObserver | null = null const watchers = new Map<Element, (size: Size) => void>() /** Watch an element's size through the one observer every sparkline shares. Returns the unobserve. */ export function observeSize(element: Element, cb: (size: Size) => void): () => void { if (typeof ResizeObserver === "undefined") return () => {} observer ??= new ResizeObserver((entries) => { for (const entry of entries) { const box = entry.contentBoxSize?.[0] watchers.get(entry.target)?.(box ? { width: box.inlineSize, height: box.blockSize } : { width: entry.contentRect.width, height: entry.contentRect.height }) } }) watchers.set(element, cb) observer.observe(element) return () => { watchers.delete(element) observer?.unobserve(element) } } /** Test hook: forget the shared observer. */ export function resetSizeObserver() { observer?.disconnect() observer = null watchers.clear() } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); }
Usage
import { Sparkline } from "@/components/ui/sparkline"
const prices = [99.5, 99.52, 99.51, 99.56, 99.54, 99.55]
function PriceHistory() {
return <Sparkline values={prices} label="ABC, 10:00–10:05" width={160} height={40} format={(value) => value.toFixed(2)} />
}
Supply both dimensions when the surrounding cell has a known size. These six readings are one minute apart. The accessible name reports direction, last price, low, and high using the supplied decimal formatter. No extra formatter installation is needed.
Gaps and a baseline
Missing readings keep their places on the time axis. This fixed series has gaps at 10:02 and 10:04, leaving the 10:03 reading as a dot. Each connected run gets its own area fill.
The dashed line is the previous close, 99.60. Although the last reading is above the first, it is below that baseline, so the automatic direction is down. The caption states the comparison as well.
import { Sparkline } from "@/components/ui/sparkline"
const prices = [99.5, 99.52, null, 99.56, null, 99.53, 99.55]
const previousClose = 99.6
export default function SparklineBaselineDemo() {
return (
<figure className="w-52 max-w-full space-y-3 text-xs lining-nums tabular-nums">
<Sparkline values={prices} baseline={previousClose} label="ABC, 10:00–10:06, against previous close" width={208} height={64} format={(value) => value.toFixed(2)} />
<figcaption className="text-muted-foreground">Last: 99.55. Down from the previous close of {previousClose.toFixed(2)}.</figcaption>
</figure>
)
}
Responsive crosshair
Omit both dimensions and size the box with classes to let Sparkline measure it. Narrow chart changes the wrapper's width; the chart remains capped at the available preview width. The shared alignment controls choose which edge stays fixed while the width changes.
Move the pointer over the chart, or focus it and use the arrow keys, Home, End, and Escape. The crosshair names the original sample minute and price. There is no 10:02 reading, so navigation skips from 10:01 to 10:03. These readings stay fixed while you inspect them.
import { useState } from "react"
import { Sparkline } from "@/components/ui/sparkline"
const prices = [99.5, 99.52, null, 99.56, 99.54, 99.55]
export default function SparklineInteractiveDemo() {
const [narrow, setNarrow] = useState(false)
return (
<>
<div data-demo-controls className="text-xs"><label className="flex items-center gap-1"><input type="checkbox" checked={narrow} onChange={(event) => setNarrow(event.target.checked)} />Narrow chart</label></div>
<div className="max-w-full space-y-3 text-xs" style={{ width: narrow ? 192 : 384 }}>
<div className="pt-5">
<Sparkline values={prices} label="ABC, 10:00–10:05, interactive history" interactive format={(value) => value.toFixed(2)} pointLabel={(index) => `10:0${index}`} className="h-24 w-full" />
</div>
<p className="text-muted-foreground">Focus the chart and use the arrow keys. Home and End select the first and last readings; Escape hides the crosshair.</p>
</div>
</>
)
}
API Reference
SparklineProps extends the root div's props except children.
| Prop | Type | Default | Purpose |
|---|---|---|---|
values |
readonly (number | null | undefined)[] |
Required | Readings in order; non-finite or missing readings leave gaps. |
label |
string |
Required | Accessible name, such as ZN, last 30 minutes. |
direction |
"auto" | "up" | "down" | "flat" | "none" |
"auto" |
Compare readings automatically or choose the color explicitly. Exported as SparklineDirection. |
baseline |
number |
Omitted | Comparison level and dashed line, such as a previous close. Use a finite number. |
width, height |
number each |
Omitted | Fixed dimensions in pixels only when both are supplied; otherwise the component measures its box. |
area |
boolean |
true |
Tint beneath each unbroken run of two or more readings. |
interactive |
boolean |
false |
Enable a pointer and keyboard crosshair when measured or fixed geometry has finite readings. |
format |
(value: number) => string |
String(value) |
Format the summary and crosshair value. |
pointLabel |
(index: number) => string |
Omitted | Prefix the crosshair value with a label for the reading's original index in values. |
className |
string |
Omitted | Merge classes with the root's defaults, including its responsive size. |
style |
CSSProperties |
Omitted | Style the root; fixed width and height take precedence over the same style properties. |
Replace the values array when readings change so the memoized geometry recalculates. The root has data-slot="tradecn-sparkline", data-direction, and data-empty when there are no finite readings. Caller-supplied role, tabIndex, and ARIA props can override the generated defaults. A ref receives the root element; the component owns those three data attributes.
What it says
With direction="auto", the color compares the last finite reading with the first, or with baseline when supplied. Zero change is flat, using the same directionOf as the flash cell. An explicit up, down, or flat overrides that comparison. none uses the foreground color and omits the direction word from the summary.
In image mode, the accessible name includes the label, direction, last reading, low, and high: ZN today: up, last 99-17, low 99-16, high 99-17. Values pass through format, so use the formatter your grid uses. Low and high describe the finite readings, excluding the baseline. The inner SVG is hidden from assistive technology.
Gaps
null, undefined, NaN, and infinities break the line. Each reading keeps the x position of its original array index; gaps do not pull later readings left. Indices are evenly spaced, so supply samples at the intervals you want to show. A run containing one reading is a dot, with no area fill; longer connected runs each get their own fill. A one-element array places its dot at the horizontal center.
The scale spans the finite readings and a finite baseline, when present. A constant series sits at the vertical center only when the baseline is absent or equal to that value. Otherwise the baseline expands the range. The dashed baseline stays within that range when there is data; the area fill closes to the plot's bottom, not to the baseline.
An empty array or an array of only gaps draws no series or baseline, sets data-empty, and has the accessible name <label>: no data. It remains an image even with interactive. A non-finite baseline is omitted from the geometry but makes automatic direction flat; sanitize it before passing it.
Size
Supply both width and height for a fixed size with no observer. This suits grid columns whose dimensions are already known. If either is omitted, both dimensions come from the measured box; a lone dimension prop does not size it.
Responsive mode defaults to h-6 w-24; use className or style to size the box. Instances share one ResizeObserver. The SVG appears after the first size notification, not on a guaranteed frame. Until then, the root is an image; automatic direction and the summary read the data itself, so the words are right before the first measurement and in server output. Without ResizeObserver, a newly mounted responsive sparkline never gets geometry; supply both dimensions in that environment or for server-rendered SVG.
Crosshair
By default the root is an image with no tab stop or built-in keyboard navigation. With interactive and at least one geometry point, it becomes a horizontal slider named by label, with tabIndex={0}. Focus initializes an unset selection to the last finite reading.
| Input | Result |
|---|---|
| Left / Right, Down / Up | Move one finite reading backward / forward. |
| PageDown / PageUp | Move ten finite readings backward / forward. |
| Home / End | Select the first / last finite reading. |
| Escape | Hide the crosshair without moving focus. |
| Ctrl, Cmd, or Alt with any key | Left to listeners above the sparkline, such as a hotkey registry. |
| Pointer move | Select the nearest finite reading by x position. |
| Pointer leave | Hide the crosshair unless the root has focus. |
| Blur | Hide the crosshair. |
Navigation skips gaps and stops at the ends. The crosshair holds a position among the finite readings, not a reading: under a live sliding window, a parked crosshair names whichever reading occupies its index on each tick, and moves sideways as gaps slide through. If the series shrinks, a selection beyond its end displays the last remaining point, and with no readings left, or with interactive off, the chart forgets its crosshair, so readings that return draw none until a pointer or focus picks one. The readout uses format, prefixed by pointLabel(index) when supplied. It draws in the 1rem above the chart, 16 px at the default size, so reserve that headroom — the demo uses pt-5 — or an ancestor that clips overflow, a panel or a grid cell among them, cuts it off; in a narrow cell a long readout can also overflow to the side. The slider's numeric range is 0 through points.length - 1, counting only finite readings; aria-valuetext contains the readout while a point is active and the full summary otherwise. The spoken words — up, down, flat, last, low, high, no data — are fixed English. In image mode, override aria-label with your own summary. On the slider, format and pointLabel localize the readout and the active aria-valuetext; the resting aria-valuetext keeps the English summary whenever no point is active.
Caller handlers run first. Calling preventDefault() in onKeyDown cancels built-in keyboard navigation; focus, blur, and pointer handling do not check that flag. The component prevents default for its navigation keys and Escape, with or without Shift; it leaves Ctrl, Cmd, and Alt chords and other keys alone, and does not stop propagation. Outer grids and hotkey dispatchers should respect defaultPrevented.
The geometry, on its own
buildSparklineGeometry(values, width, height, options?) is a pure function in @/lib/sparkline-geometry, with no React dependency. Use it for a canvas renderer or server-rendered SVG. It takes the same values type as the component, numeric dimensions, and these SparklineOptions:
| Option | Type | Default | Purpose |
|---|---|---|---|
padding |
number |
2 |
Inset in pixels; negative padding clamps to zero. |
baseline |
number |
Omitted | Include a finite level in the scale and calculate its y position. |
Coordinates round to two decimal places. The inner width and height each clamp to at least one pixel. The returned SparklineGeometry contains:
| Member | Type | Meaning |
|---|---|---|
line |
string |
SVG path data, one subpath per unbroken run; isolated readings use a zero-length stroke with a round cap in the component. |
area |
string |
Paths for runs of at least two readings, closed to the plot's bottom. |
points |
SparklinePoint[] |
Finite readings in order. Each point has numeric index, value, x, and y; index counts gaps in the input. |
min, max |
number each |
Extrema of the finite readings, excluding the baseline. |
baselineY |
number | null |
Baseline y coordinate, or null if absent, non-finite, or there are no finite readings. |
With no finite readings, the result is { line: "", area: "", points: [], min: 0, max: 0, baselineY: null }.
| Helper | Return | Behavior |
|---|---|---|
nearestPointIndex(points: readonly SparklinePoint[], x: number) |
number |
Binary search of points ordered by x. Returns a position in points, not the original reading index, or -1 for an empty array. |
observeSize(element: Element, cb: (size: Size) => void) |
() => void |
Subscribe through the shared observer; call the returned function to unobserve. Size has numeric width and height. Without ResizeObserver, the callback is never called. |
observeSize stores one callback per element. Registering again replaces it, and either cleanup removes the current registration. resetSizeObserver() is a test hook that disconnects the shared observer and clears its subscriptions.
Tokens
The install adds the market tokens if you do not have them: up, down, flat, and their -soft variants, the same ones flash-cell uses.
What it does not do
No axes, ticks, multiple series, zoom, or animation between values. It draws every finite reading without decimation; thin a history much longer than the box is wide before passing it in.
This page is docs/sparkline.md at v2.0.0.