Skip to main content

Line Chart

A canvas-rendered line chart for trends over a continuous x axis, time above all. Rendering is canvas with min/max decimation, so series of 100,000+ points stay smooth. The entire interaction contract ships with LineChart.Root: a crosshair snapped to the nearest x with an every-series tooltip, keyboard stepping, polite announcements, a screen-reader description, and a screen-reader data table that mounts once the chart takes focus. No part removal can strip keyboard access to the data.

When to use

A LineChart answers "how is this changing?" and "do these series move together?": latency percentiles, request rates, resource usage over time. Lines encode value by vertical position along a shared y axis, which makes them the right mark for trends over a continuous x.

  • Prefer BarChart to compare discrete categories: bars encode value by length from a zero baseline.
  • Prefer AreaChart when cumulative composition matters: stacked areas show how parts sum to a total.

The x scale is auto-detected from the first row's x value: Date values get a "time" scale with calendar-aware ticks, numbers get "linear", and strings get "point" (evenly spaced categories). Pass xScale when detection guesses wrong. For example, epoch-millisecond numbers auto-detect as "linear", so pass xScale="time" to read them as time.

Keep it to eight series or fewer. The default chart tokens are eight validated categorical slots. The ninth series onward all wear the neutral --color-chart-other, so a dozen keys legend as a wall of identical gray. Eight is the palette's measured limit, not a style preference: all 28 pairs of the eight clear the colorblind-safe gates, and the closest pair clears its floor by 0.054 ΔE. A ninth hue has nowhere to sit. Rank by the window's total, keep the top seven, and sum the rest into one "Other" series. The Bar Chart page carries a worked fold. Set the fold's own Line to seriesSlot="other", so it reads as the tail rather than taking a categorical slot.

Compose LineChart.Root with one LineChart.Line per series. Every chart requires an accessible name (aria-label or aria-labelledby), and every multi-series chart composes LineChart.Legend — identity must never rely on color-matching alone. Hover or focus the chart and arrow around: the tooltip reads every series at the active x.

Line chart with 30 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; const latencyStart = new Date("2026-07-18T10:00:00Z").getTime();const latencyPoint = (minute: number) => ({	time: new Date(latencyStart + minute * 60_000),	p50: Math.round(120 + 18 * Math.sin(minute / 4) + 6 * Math.sin(minute / 1.7)),	p99: Math.round(480 + 80 * Math.sin(minute / 5 + 1) + 24 * Math.sin(minute / 2.3)),});const latencyData = Array.from({ length: 30 }, (_, minute) => latencyPoint(minute)); function LatencyExample() {	return (		<LineChart.Root data={latencyData} xKey="time" aria-label="Request latency by percentile">			<LineChart.Grid />			<LineChart.XAxis />			<LineChart.YAxis tickFormat={(value) => `${value}ms`} />			<LineChart.Line dataKey="p50" label="p50" />			<LineChart.Line dataKey="p99" label="p99" />			<LineChart.Tooltip valueFormat={(value) => `${value}ms`} />			<LineChart.Legend />		</LineChart.Root>	);}

Curves, markers, and shapes

curve controls interpolation between points: "linear" (the default), "monotone" (smoothed without overshooting the data), or "step". markers draws a dot with a surface ring at each point. Markers auto-hide when points get denser than ~12px apart, so a chart that grows dense never turns to gravel. shape picks the marker glyph per series, and the hover dot and legend key wear it too.

Eight glyphs match the palette's eight series slots: "circle", "square", "triangle", "diamond", "triangle-down", "plus", "cross", "star", in that order. A series that sets no shape wears the glyph of its slot. Neither line below sets one: this week takes slot 1 and its circle, last week slot 2 and its square, on the markers, in the legend, and under the crosshair. Every chart therefore carries a second identity channel by default. That channel matters where color cannot carry identity alone: a reader with a color vision deficiency, a grayscale print or export, forced-colors mode. Set seriesSlot={3} for a fixed triangle identity. Set shape to override only the glyph. All chart families share the glyph set; Point shapes on the scatter plot page is the full treatment.

String x values put the chart on a "point" scale: evenly spaced categorical positions with no band thickness.

Line chart with 7 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; const signupsData = [	{ day: "Mon", thisWeek: 42, lastWeek: 38 },	{ day: "Tue", thisWeek: 61, lastWeek: 47 },	{ day: "Wed", thisWeek: 55, lastWeek: 60 },	{ day: "Thu", thisWeek: 67, lastWeek: 52 },	{ day: "Fri", thisWeek: 74, lastWeek: 66 },	{ day: "Sat", thisWeek: 30, lastWeek: 34 },	{ day: "Sun", thisWeek: 26, lastWeek: 22 },]; function CurveMarkersExample() {	return (		<LineChart.Root data={signupsData} xKey="day" aria-label="Signups by day of week">			<LineChart.Grid />			<LineChart.XAxis />			<LineChart.YAxis />			<LineChart.Line dataKey="thisWeek" label="This week" curve="monotone" markers />			<LineChart.Line dataKey="lastWeek" label="Last week" curve="monotone" markers />			<LineChart.Tooltip />			<LineChart.Legend />		</LineChart.Root>	);}

High-density series

This chart plots a 50,000-point seeded pseudo-random walk on a "linear" x scale. Past 4 points per device pixel, the engine decimates each series to its min/max per device-pixel column, so a redraw costs O(plot width), not O(points). It is also visually lossless, because a line through every intermediate value paints the same column of pixels anyway. The idle chart costs zero CPU: there is no always-on render loop; every mutation coalesces into a single requestAnimationFrame commit. And hover never repaints the canvas: the crosshair, active-point markers, and tooltip are DOM overlay elements moved with transforms.

Line chart with 50000 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; function seededWalk(count: number, seed: number) {	// Deterministic LCG (Numerical Recipes constants) — renders identically	// on every load, no Math.random.	let state = seed >>> 0;	const nextRandom = () => {		state = (state * 1664525 + 1013904223) >>> 0;		return state / 4294967296;	};	let value = 250;	return Array.from({ length: count }, (_, sample) => {		value += nextRandom() * 2 - 1;		return { sample, value: Math.round(value * 100) / 100 };	});}const walkData = seededWalk(50_000, 20_260_718); function HighDensityExample() {	return (		<LineChart.Root data={walkData} xKey="sample" aria-label="Seeded random walk, 50,000 samples">			<LineChart.Grid />			<LineChart.XAxis />			<LineChart.YAxis />			<LineChart.Line dataKey="value" label="Value" />			<LineChart.Tooltip />		</LineChart.Root>	);}

Gaps and connectNulls

null and missing values render as gaps by default, and gaps are the honest default: a gap says "we did not measure this," where a bridged line invents values that were never recorded. Pass connectNulls to join across missing values only when a gap genuinely means "unchanged" rather than "unknown." Gapped values read as an em dash in the tooltip and as "no data" in keyboard announcements.

Line chart with 12 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; const sensorData = [	{ hour: "00:00", roof: 21.1, lobby: 20.9 },	{ hour: "02:00", roof: 20.4, lobby: 20.7 },	{ hour: "04:00", roof: null, lobby: null },	{ hour: "06:00", roof: null, lobby: null },	{ hour: "08:00", roof: 22.8, lobby: 21.4 },	{ hour: "10:00", roof: 25.2, lobby: 21.9 },	{ hour: "12:00", roof: 27.6, lobby: 22.3 },	{ hour: "14:00", roof: 28.1, lobby: 22.5 },	{ hour: "16:00", roof: 26.9, lobby: 22.2 },	{ hour: "18:00", roof: 24.3, lobby: 21.8 },	{ hour: "20:00", roof: 22.6, lobby: 21.3 },	{ hour: "22:00", roof: 21.7, lobby: 21.0 },]; function GapsExample() {	return (		<LineChart.Root			data={sensorData}			xKey="hour"			aria-label="Sensor temperature with an outage window"		>			<LineChart.Grid />			<LineChart.XAxis />			<LineChart.YAxis tickFormat={(value) => `${value}°C`} />			<LineChart.Line dataKey="roof" label="Roof (gaps)" />			<LineChart.Line dataKey="lobby" label="Lobby (connectNulls)" connectNulls />			<LineChart.Tooltip valueFormat={(value) => `${value}°C`} />			<LineChart.Legend />		</LineChart.Root>	);}

Reference lines

LineChart.ReferenceLine paints a dashed horizontal marker at a y value — an SLO threshold, a budget, a limit — in the neutral reference color, with an optional label above its right end. Two conventions worth copying here: a series that carries bad meaning (an error rate) wears a semantic status color instead of a categorical chart slot, and yDomain anchors the rate at zero so the distance to the threshold reads truthfully.

Line chart with 24 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; const errorRateData = Array.from({ length: 24 }, (_, hour) => ({	time: new Date(Date.UTC(2026, 6, 17, hour)),	errorRate: Number((0.62 + 0.35 * Math.sin(hour / 3) + 0.22 * Math.sin(hour / 1.3)).toFixed(2)),})); function ReferenceLineExample() {	return (		<LineChart.Root			data={errorRateData}			xKey="time"			yDomain={[0, "auto"]}			aria-label="Error rate against the SLO"		>			<LineChart.Grid />			<LineChart.XAxis />			<LineChart.YAxis tickFormat={(value) => `${value}%`} />			<LineChart.Line dataKey="errorRate" label="Error rate" color="var(--color-danger-600)" />			<LineChart.ReferenceLine y={1} label="SLO" />			<LineChart.Tooltip valueFormat={(value) => `${value}%`} />		</LineChart.Root>	);}

Linked charts

The active position is controllable: pass activeIndex and onActiveIndexChange to share one crosshair across charts plotted over the same rows (the dashboard "linked crosshair" pattern). Hover or arrow through either chart and both move together, so a latency spike and its error spike line up without eyeballing timestamps.

Line chart with 60 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
Line chart with 60 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart";import { useState } from "react"; const trafficStart = new Date("2026-07-18T10:00:00Z").getTime();const trafficPoint = (minute: number) => ({	time: new Date(trafficStart + minute * 60_000),	requests: Math.round(1200 + 260 * Math.sin(minute / 5) + 90 * Math.sin(minute / 1.9)),	errors: Math.round(14 + 9 * Math.sin(minute / 3.2 + 2) + 4 * Math.sin(minute / 1.4)),});const trafficData = Array.from({ length: 60 }, (_, minute) => trafficPoint(minute)); function LinkedChartsExample() {	const [activeIndex, setActiveIndex] = useState<number | null>(null);	return (		<div className="flex w-full flex-col gap-4">			<LineChart.Root				data={trafficData}				xKey="time"				aria-label="Requests per minute"				activeIndex={activeIndex}				onActiveIndexChange={setActiveIndex}				className="aspect-[3/1]"			>				<LineChart.Grid />				<LineChart.XAxis />				<LineChart.YAxis />				<LineChart.Line dataKey="requests" label="Requests" />				<LineChart.Tooltip />			</LineChart.Root>			<LineChart.Root				data={trafficData}				xKey="time"				aria-label="Errors per minute"				activeIndex={activeIndex}				onActiveIndexChange={setActiveIndex}				className="aspect-[3/1]"			>				<LineChart.Grid />				<LineChart.XAxis />				<LineChart.YAxis />				<LineChart.Line dataKey="errors" label="Errors" color="var(--color-danger-600)" />				<LineChart.Tooltip />			</LineChart.Root>		</div>	);}

Live data

For streaming data, append points to the end of a sliding window and let the chart supply the motion. When appends arrive at a steady rate, the x domain scrolls at one constant speed. The chart measures the time between appends and glides the domain to each new target over that interval, so back-to-back appends read as one continuous scroll, never ease-stop-jump ticks. A point that arrives early or late changes the speed from the current position, with no jump. As the window slides, tick labels fade in and out per label (gridlines share their labels' fade) instead of popping between tick sets. prefers-reduced-motion snaps everything: appends land instantly, with no glide and no fades.

This demo appends one point every 150ms to a sliding 240-point window, dropping the oldest point each time. Both series derive from pure sine compositions over a tick counter and a fixed epoch (a slow drift plus faster waves), so the demo renders deterministically; a real chart would append measurements as they arrive.

Line chart with 240 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart";import { useEffect, useState } from "react"; const liveStart = new Date("2026-07-18T12:00:00Z").getTime(); const formatClock = new Intl.DateTimeFormat("en-US", {	hour: "2-digit",	minute: "2-digit",	second: "2-digit",	hour12: false,	timeZone: "UTC",}); /** One deterministic point per tick: a slow drift plus faster waves per percentile. */function livePoint(tick: number) {	const drift = Math.sin(tick / 160);	const wave = Math.sin(tick / 14);	const ripple = Math.sin(tick / 5.3);	return {		time: new Date(liveStart + tick * 150),		p50: Math.round(120 + 12 * drift + 14 * wave + 5 * ripple),		p99: Math.round(470 + 45 * Math.sin(tick / 130 + 2) + 48 * wave + 20 * ripple),	};} function LiveLineExample() {	const [tick, setTick] = useState(0); 	useEffect(() => {		const interval = setInterval(() => {			setTick((current) => current + 1);		}, 150);		return () => clearInterval(interval);	}, []); 	// A sliding 240-point window: each tick appends one point and drops the oldest.	const data = Array.from({ length: 240 }, (_, index) => livePoint(tick + index)); 	return (		<LineChart.Root data={data} xKey="time" aria-label="Live request latency by percentile">			<LineChart.Grid />			<LineChart.XAxis				tickFormat={(value) => (value instanceof Date ? formatClock.format(value) : String(value))}			/>			<LineChart.YAxis tickFormat={(value) => `${value}ms`} />			<LineChart.Line dataKey="p50" label="p50" />			<LineChart.Line dataKey="p99" label="p99" />			<LineChart.Tooltip				valueFormat={(value) => `${value}ms`}				labelFormat={(value) => (value instanceof Date ? formatClock.format(value) : String(value))}			/>			<LineChart.Legend />		</LineChart.Root>	);}

Copy as markdown

Every chart renders its data as real DOM once it takes focus: the screen-reader data table carries exact machine-readable values (ISO timestamps in <time dateTime> and raw numbers in data-value), so an agent that focuses the chart gets the series, not a picture of it. LineChart.CopyButton is the hand-off counterpart: one click copies the current rows to the clipboard as a GitHub-flavored markdown table, one x column plus one column per series in paint order. The output is machine-stable (ISO 8601 dates, plain un-separated numbers, an em dash for gaps), so it stays parseable wherever it lands: Slack, an issue, a spreadsheet, an LLM chat.

The button renders in flow where composed; Root is relatively positioned, so className can dock it over a corner instead.

Line chart with 30 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; <LineChart.Root data={latencyData} xKey="time" aria-label="Request latency, copyable">	<LineChart.Grid />	<LineChart.XAxis />	<LineChart.YAxis tickFormat={(value) => `${value}ms`} />	<LineChart.Line dataKey="p50" label="p50" />	<LineChart.Line dataKey="p99" label="p99" />	<LineChart.Tooltip valueFormat={(value) => `${value}ms`} />	<LineChart.Legend />	<LineChart.CopyButton className="absolute right-0 top-0" /></LineChart.Root>;

Lists of charts

A page of 100 charts pays for the handful a reader can see. Every LineChart.Root ingests its data and answers the keyboard from its first render, but it paints and allocates its canvas only once it comes within one viewport of the visible area or takes focus. Three viewports away it releases its canvas memory and repaints on the way back; printing paints every chart. The screen-reader data table waits for focus alone: it mounts once a reader tabs into the chart, so a page of sparklines holds no hidden tables until a reader asks for one. Add content-visibility: auto to your list's cards as a complement: it skips style and layout for offscreen cards, and the chart's own gate already skips paint and canvas memory.

Modes

mode sets how much of the chart's contract a Root exposes. Pick by what a reader does with the chart.

modeUse it forA reader gets
"interactive" (default)A chart a reader explores: a dashboard panel, a detail page, a report with a question behind it.A named role="application" Tab stop, hover and keyboard point cursor, a tooltip readout, polite announcements, and an sr-only data table on focus.
"static"A chart a reader glances at while the exact values sit beside it: a sparkline in a table row or a card, a trend in a print report.A named role="img" with a text description (kind, count, range, series), no Tab stop, no hover, no tooltip, no table. animate defaults to false.
"decorative"A placeholder whose values are not information: synthetic a line behind an empty state, a hero backdrop.Nothing: hidden from assistive technology, out of the tab order, inert, every series in one neutral fill.

Noninteractive is not decorative. A sparkline beside a total still tells a reader when activity happened and how it varied, so it keeps a name and a description; only its values live in the text beside it. One hundred sparklines in a table as one hundred Tab stops is a focus order no reader wants, and a readout that only a pointer can reach fails WCAG 1.4.13, so "static" offers neither. A chart that is the only source of its values stays "interactive".

Static charts

Set mode="static" for a chart a reader glances at while the exact values sit beside it. The plot is a named role="img": it takes the same aria-label or aria-labelledby an interactive chart does, and its aria-describedby points at the description, so a screen reader hears the chart kind, the point count, the x range, and the series labels without a Tab stop. There is no interaction overlay: no hover band, no tooltip, no keyboard point cursor, no data table, and no live region, and a composed LineChart.Tooltip has no effect. Pointer events pass through to the row or card behind it. The series keep their colors. animate defaults to false, because a list of sparklines that each play a reveal as the reader scrolls is motion with no information in it; pass animate to play the reveal. The interaction props (activeIndex, onActiveIndexChange, onDatumActivate) are forbidden at the type level. The Sparklines in a List recipe is the worked example.

<div className="flex items-center gap-2">	<LineChart.Root		data={service.requests}		xKey="day"		mode="static"		aria-label={`Requests for ${service.name}, last 30 days`}		className="aspect-auto h-6 w-28"	>		<LineChart.Line dataKey="count" label="Requests" />	</LineChart.Root>	<span className="text-muted text-xs tabular-nums">{compact(service.requestTotal)}</span></div>

Decorative charts

Set mode="decorative" to render the chart as a placeholder backdrop instead of real data: a synthetic line behind an empty state, an atmospheric hero graphic. A decorative chart keeps its visual rendering and animation but drops every layer that only makes sense for real data: it is hidden from assistive technology, removed from the tab order, and inert to pointer and keyboard, with no hover band, tooltip, sr-only data table, or live region. Because the values aren't information, mode="decorative" is mutually exclusive with the accessible-name props (aria-label / aria-labelledby) and the interaction props (activeIndex, onActiveIndexChange, onDatumActivate); the type won't compile with both.

A decorative chart also drops the categorical palette. Every series paints --color-chart-decorative, one neutral fill that outranks a series' color and seriesSlot. A backdrop carries no series identity, and a hue on values that are not information invites a reader to decode it.

That fill is what keeps an overlaid message readable. Empty.Description sits on text-muted, which measures 4.88:1 on a bare light card: 0.38 over the 4.5:1 floor for 14px text, so any tint under that copy spends the headroom. --color-chart-decorative is the darkest neutral step that still clears the floor in both themes: 4.56:1 in light and 4.93:1 in dark. The message needs no scrim behind it, no dimming, and no blur. Redeclare the token on the Root to retune the fill (className="[--color-chart-decorative:var(--color-neutral-100)]") and re-measure the copy against the value you pick.

Reach for mode="decorative" over hand-rolled inert + CSS: it removes the interaction overlay at the source, so consumers never target chart internals to neutralize it. Keep the real empty-state message as a sibling of the chart (not inside it) so screen readers still announce it; the decorative chart itself is silent.

mode="decorative" says the values are not information; it is not a disabled flag (a chart is not a form control). For real data a reader only glances at, use mode="static".

No latency data yet

Percentile latencies will appear here once requests flow through your endpoints.

import { Empty } from "@ngrok/mantle/empty";import { LineChart } from "@ngrok/mantle/line-chart"; /** Synthetic line — a backdrop, not data — so no Math.random and no real values. */const placeholderLatency = [120, 138, 126, 152, 141, 168, 150, 162].map((value, index) => ({	minute: index,	value,})); function EmptyStateLineChartExample() {	return (		<div className="relative w-full">			<LineChart.Root data={placeholderLatency} xKey="minute" mode="decorative">				<LineChart.Line dataKey="value" curve="monotone" />			</LineChart.Root>			{/* The real message is a sibling of the chart, so AT still announces it. */}			<Empty.Root className="absolute inset-0 m-auto h-fit w-fit">				<Empty.Title>No latency data yet</Empty.Title>				<Empty.Description>					<p>Percentile latencies will appear here once requests flow through your endpoints.</p>				</Empty.Description>			</Empty.Root>		</div>	);}

Composition

Compose the parts of a LineChart together to build your own:

LineChart.Root├── LineChart.Grid├── LineChart.XAxis├── LineChart.YAxis├── LineChart.Line (one per series)├── LineChart.ReferenceLine├── LineChart.Tooltip├── LineChart.Legend└── LineChart.CopyButton

Accessibility

Every interactive chart must have an accessible name: either a direct aria-label or an aria-labelledby reference to a visible title (preferred when the chart sits under a heading — no drift-prone duplicated text). This is enforced at the type level: omitting both is a compile error, not a runtime warning. The name lands on the focusable interaction overlay, the chart's single named element (the canvas itself is hidden from assistive technology so the chart is never announced twice). A static chart names its plot role="img" instead and has no Tab stop. A decorative chart carries no real data, so it takes no accessible name and is hidden from assistive technology entirely.

The chart is a single tab stop with a full keyboard point cursor:

KeyBehavior
TabFocus the chart — one tab stop regardless of point count.
ArrowLeft / ArrowRightStep to the previous/next data point. When decimated, arrows stride by one device-pixel column of data, so a 50,000-point chart stays traversable in ~plot-width presses.
PageUp / PageDownJump forward/backward by 10% of the data.
Home / EndJump to the first/last point.
Enter / SpaceActivate the current point — fires onDatumActivate.
EscapeDismiss the crosshair and tooltip.

Keyboard stepping announces the active point (the x value and every series value) through a debounced polite aria-live region, so rapid arrowing never floods the screen-reader queue.

The overlay's aria-describedby points at a screen-reader description, data-slot="line-chart-description", so a screen reader hears what the chart holds when it takes focus: the chart kind, the number of data points, the x range from the first point to the last, the series labels, and the keyboard instructions. For example: "Line chart with 30 data points from Jul 1, 2026 to Jul 30, 2026. Series: Requests, Errors. Use the left and right arrow keys to move between data points, …". An empty chart reads "Line chart with no data." in place of the count and range. Server HTML carries the plain count alone. The range waits for hydration, because Intl output differs by locale, and the series labels register in layout effects, so both render on the client.

Every chart also renders a screen-reader-only data table once it takes focus: one column for the x value and one per series, bounded to the first 150 rows with a caption stating the summarization (an unbounded table at 100,000 rows would be unusable to assistive tech anyway). A LineChart.Root mounts dormant and engages once focus lands inside it (the overlay is the Tab stop, and a focusable part composed inside the Root, such as LineChart.CopyButton, counts too), and it never goes dormant again. Visibility paints the chart but never mounts the table. The Root stamps data-state="dormant", then data-state="engaged", and the table mounts with the flip, so a screen reader's table list names only the charts the reader has focused. The description above names what every chart holds, focused or not. Tooltips enhance, they never gate: every plotted value is reachable through the table or the keyboard point cursor.

The table doubles as the chart's machine-readable surface. Date row headers carry the exact ISO timestamp in a <time dateTime> attribute and value cells carry the raw number in data-value, so agents and scrapers read precise data from the DOM without reverse-engineering locale formatting. Assistive tech still hears the human-formatted text, and the table itself is addressable at data-slot="line-chart-data-table". To reach it, an agent focuses the chart (a scroll into view is not enough), then waits for data-state="engaged" on the Root. LineChart.CopyButton hands over the same rows as markdown with neither step.

API Reference

LineChart.Line, LineChart.Grid, LineChart.XAxis, LineChart.YAxis, and LineChart.ReferenceLine are renderless: they register configuration with the Root, which paints everything on canvas. They accept no className and no DOM props. LineChart.Tooltip, LineChart.Legend, and LineChart.CopyButton are the parts that touch real DOM — the first two accept div props, CopyButton accepts button props.

LineChart.Root

The root of a line chart: owns the data, the scales, the canvas renderer, and the entire interaction contract. Renders a <div> wrapper (defaulting to a 16:9 aspect ratio, overridable via className) containing the canvas, the focusable interaction overlay, the sr-only description, the crosshair, hover-band, and marker layers, the aria-live region, and, once focus lands inside the chart, the sr-only data table. Lines encode value by vertical position, not length from a baseline, so the full y domain is overridable via yDomain. Lines never stack, so there is no stacked prop.

All props from div, plus:

PropTypeDefaultDescription
datareadonly TDatum[]Rows of data; each LineChart.Line reads one numeric key per row.
xKeyExtract<keyof TDatum, string>The key of each row's x value.
aria-labelstringThe chart's accessible name. Exactly one of aria-label or aria-labelledby is required — enforced at the type level. Required unless mode is \"decorative\", which forbids it.
aria-labelledbystringThe id of a visible element that names the chart. Preferred over aria-label when the chart sits under a heading. Required unless mode is \"decorative\", which forbids it.
mode?"interactive" | "static" | "decorative""interactive"How much of the contract the Root exposes: explore it ("interactive"), glance at it with its values beside it ("static": a named role="img", no Tab stop, no tooltip, no table, animate off by default), or paint a placeholder ("decorative": hidden from assistive technology, one neutral fill). The name props are required unless decorative, and the interaction props are allowed only when interactive. See Modes.
xScale?"time" | "linear" | "point"auto-detectedOverride the auto-detected x scale (Date values → "time", numbers → "linear", strings → "point").
yDomain?[number | "auto", number | "auto"]["auto", "auto"]Override the automatic y domain at either end.
animate?booleantrueAnimate enter/update transitions; the enter transition plays when the chart first comes into view. prefers-reduced-motion always wins.
pending?booleanfalseHold the previous render at reduced opacity while fresh data loads — never swap a chart that has rendered once for a skeleton.
activeIndex?number | nullControlled active datum index (null = none). Omit for uncontrolled hover. Allowed only when mode is "interactive".
onActiveIndexChange?(index: number | null) => voidNotified when hover/keyboard moves the active index. Allowed only when mode is "interactive".
onDatumActivate?(event: ChartDatumEvent) => voidNotified on datum activation: mark click, or Enter/Space on the focused chart. The event carries index, xValue, datum, and the dataKey of the series nearest the pointer (null for keyboard activation). Allowed only when mode is "interactive".

Data attributes

Data AttributeValueDescription
data-slot"line-chart"The Root wrapper, with any forwarded chain ahead of it.
data-slot"line-chart-plot"The plot box. Every overlay layer positions against it, and the resize observer measures it.
data-slot"line-chart-canvas"The <canvas> the marks paint on. Always aria-hidden. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print).
data-slot"line-chart-crosshair"The vertical hairline snapped to the active x. The engine writes its geometry as an inline style. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print). Interactive charts only: a static or decorative chart inserts the canvas alone.
data-slot"line-chart-hover-band"The category highlight; a line chart never shows it. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print). Interactive charts only: a static or decorative chart inserts the canvas alone.
data-slot"line-chart-markers"The container the engine syncs one hover dot per series into. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print). Interactive charts only: a static or decorative chart inserts the canvas alone.
data-slot"line-chart-tooltip"The tooltip surface. The Root renders it on the first hover or keyboard step and keeps it; LineChart.Tooltip configures it. A chart nobody points at holds none.
data-slot"line-chart-description"The sr-only description the overlay's aria-describedby points at: the chart kind, the point count, the x range, the series labels, and, on an interactive chart, the keyboard instructions. Omitted when mode="decorative".
data-slot"line-chart-data-table"The sr-only data table. Mounts once focus lands inside the chart (data-state flips to "engaged") and stays. Interactive charts only.
data-state"dormant" | "engaged"On the Root. Flips to "engaged" once focus lands inside the chart, and never flips back. The data table mounts with it. Visibility paints the chart but never flips it. Interactive charts only.
data-slot"chart-active-point"One hover dot inside the markers layer. Also carries data-shape.
data-shape"circle" | "square" | "triangle" | "diamond" | "triangle-down" | "plus" | "cross" | "star"On each hover dot, naming the glyph its series wears — the shape it set, else the one paired to its series slot. The dot clips itself to that glyph, so target this only to restyle a dot.

LineChart.Line

One line series. Renderless — it registers the series with the chart, which paints it on canvas. Compose one Line per series.

PropTypeDefaultDescription
dataKeystringThe row key this series reads its numeric values from.
label?stringdataKeyDisplay name for the legend, tooltip, and data table.
seriesSlot?1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | "other"next unreserved slotGive the series a fixed color-and-shape identity. See Chart color tokens.
color?SeriesColorcolor from the resolved slotOverride only the color channel with a chart token or CSS color string. This does not change the series slot or default shape. See Chart color tokens.
curve?"linear" | "monotone" | "step""linear"Interpolation between points. "monotone" smooths without overshooting the data.
markers?booleanfalseDraw point markers with a surface ring. Auto-hidden when points are denser than ~12px apart.
connectNulls?booleanfalseJoin across null/missing values instead of leaving gaps. Gaps are the default and usually correct.
shape?"circle" | "square" | "triangle" | "diamond" | "triangle-down" | "plus" | "cross" | "star"the glyph paired to the series slotThe glyph the hover dot, canvas point markers, and legend key wear. This overrides only the shape channel. See Point shapes.

LineChart.Grid

Hairline gridlines behind the lines. Renderless — painted on canvas. Omit the part to omit the grid.

PropTypeDefaultDescription
lines?"horizontal" | "vertical" | "both""horizontal"Which gridlines to draw.

LineChart.XAxis

Tick labels along the bottom of the plot. Renderless — painted on canvas. Labels that would collide are skipped, never rotated.

PropTypeDefaultDescription
tickFormat?(value: string | number | Date, context: { defaultLabel: string }) => stringscale-dependentFormat a tick label. Receives the row's x value — a Date on time scales, a number on linear scales, and the category value on point scales. Defaults to calendar-aware formatting on time scales and thousands-separated numbers on linear scales. The second argument is { defaultLabel }: the label the axis paints without a formatter.
tickCount?numberdensity-derivedTarget tick count (continuous scales only). Approximate: a linear axis over integer-only x values never renders fractional ticks.

LineChart.YAxis

Value tick labels along the left of the plot. Renderless — painted on canvas. Ticks land on clean thousands-separated numbers.

PropTypeDefaultDescription
tickFormat?(value: number, context: { defaultLabel: string; step: number }) => stringthousands-separatedFormat a tick label. The second argument carries the tick step and the defaultLabel the axis paints without a formatter, so a formatter can add a unit to defaultLabel and keep its precision.
tickCount?number5Maximum tick count. When omitted, the axis aims for about 5 ticks. The axis can render fewer: ticks land on human-friendly steps, a vertical axis keeps about 28px for each tick, and a label that would overlap its neighbor is skipped. Integer-valued data never renders fractional ticks.

LineChart.ReferenceLine

A dashed horizontal marker at a y value — an SLO threshold, a budget, a limit. Renderless — painted on canvas.

PropTypeDefaultDescription
ynumberThe y value the line marks.
label?stringA short label painted above the line's right end.
color?SeriesColorneutral referenceLine color. Defaults to the neutral reference color in the active theme.

LineChart.Tooltip

Customizes the hover/keyboard tooltip readout. The tooltip itself is part of LineChart.Root's interaction contract and renders even when this part is absent — compose it to format the label or values, append a footer, or replace the content entirely.

A children readout renders inside a <div data-slot="line-chart-tooltip-label">. When it goes away, React removes that div instead of your own text nodes: a browser translation engine reparents text nodes, and a removal aimed at one throws. The div is display: contents, so it adds no box and your content stays a direct layout child of the parent. To style it as a box, set a display of your own on it first. Browser translation covers the mechanism and the rules your own components need.

The footer and children render props receive a HoverSnapshot: the active index, xValue, the original datum, points (every series' dataKey, label, value, and color at the active x, in registration order), and viaKeyboard. labelFormat receives the x value directly and valueFormat receives (value, dataKey) — not the snapshot.

All props from div (forwarded to the tooltip element the Root renders) except children, plus:

PropTypeDefaultDescription
labelFormat?(value: string | number | Date) => ReactNodeFormat the x/label row of the readout.
valueFormat?(value: number, dataKey: string) => ReactNodeFormat each series value.
indicator?"line" | "none""line"The per-series color key: a short stroke or nothing.
footer?ReactNode | ((snapshot: HoverSnapshot) => ReactNode)Extra content appended below the series rows (e.g. a "view logs" hint).
children?(snapshot: HoverSnapshot) => ReactNodeReplace the entire readout.

Data attributes

Data AttributeValueDescription
data-slot"line-chart-tooltip-label"On the <div> wrapping a children readout. The default readout has none.

LineChart.Legend

The legend: series names keyed by swatches carrying each series' color and glyph, rendered as real DOM in flow below the plot. Always compose it on multi-series charts — identity must never rely on color-matching alone. It renders nothing for a single series (the chart's title already names it). The legend is at most 64px tall (max-h-16) and scrolls past that, so many series never take the plot's height in a fixed-height Root. Set a max-h-* class in className to change the bound.

A children render prop renders inside a <div data-slot="line-chart-legend-label">. When it goes away, React removes that div instead of your own text nodes: a browser translation engine reparents text nodes, and a removal aimed at one throws. The div is display: contents, so it adds no box and your content stays a direct layout child of the parent. To style it as a box, set a display of your own on it first. Browser translation covers the mechanism and the rules your own components need.

All props from div except children, plus:

PropTypeDefaultDescription
children?(series: SeriesMeta[]) => ReactNodeReplace the default legend items. Receives the registered series with their dataKey, label, mark, resolved color, and resolved shape.

Data attributes

Data AttributeValueDescription
data-slot"line-chart-legend"The legend list. LineChart.Legend renders it in flow below the plot.
data-slot"line-chart-legend-label"On the <div> wrapping a children render prop. The default legend has none.
data-shape"circle" | "square" | "triangle" | "diamond" | "triangle-down" | "plus" | "cross" | "star"On the glyph riding each swatch's stroke, naming what its LineChart.Line wears. The glyph clips itself to that shape, so target this only to restyle a key.

LineChart.CopyButton

A button that copies the chart's current data to the clipboard as a GitHub-flavored markdown table: one x column plus one column per series in paint order. Values are machine-stable — ISO 8601 dates, plain un-separated numbers, an em dash for gaps — so the paste target receives parseable data, not locale formatting. Renders an icon button in flow where composed; Root is relatively positioned, so className can dock it over a corner (e.g. absolute right-0 top-0).

All props from button, plus:

PropTypeDefaultDescription
label?string"Copy data as Markdown"The accessible name, rendered as the button's aria-label.
onCopy?(value: string) => voidCallback fired after a successful copy, passes the serialized markdown.
onCopyError?(error: unknown) => voidCallback fired when an error occurs during copying.
asChild?booleanfalseCompose the copy behavior onto your own element instead of the default button.

While a copy is fresh, an always-mounted role="status" element beside the button announces "Copied" to a screen reader.

Data AttributeValueDescription
data-slot"line-chart-copy-button"The button the part renders.

Chart color tokens

The ordered --color-chart-1…--color-chart-8 tokens define the built-in series identities. Each of the two themes (light and dark) authors its own eight oklch() literals, measured so all 28 pairs hold 15 ΔE apart for full-color vision and 8 ΔE under simulated protanopia or deuteranopia, and so every slot clears 3:1 against that theme's card surface. The semantic ramps are deliberately excluded, so a series never reads as a status color.

Series receive unreserved slots in composition order. Set seriesSlot to give a series a fixed visual identity. Mantle reserves fixed slots before automatic series take the remaining slots. Removing an automatic series closes the gap. The color and shape props override only their own channels and never change slot assignment. seriesSlot="other" selects the neutral tail treatment without reserving a categorical slot. When the chart runs out of unreserved categorical slots, later automatic series paint with --color-chart-other. Fold the long tail into a single "Other" series instead. Eight is the validated limit, and no ninth hue clears it.

CSS VariableDescription
--color-chart-1The first categorical series slot (blue).
--color-chart-2The second categorical series slot (green).
--color-chart-3The third categorical series slot (pink).
--color-chart-4The fourth categorical series slot (red-orange).
--color-chart-5The fifth categorical series slot (teal).
--color-chart-6The sixth categorical series slot (magenta).
--color-chart-7The seventh categorical series slot (violet).
--color-chart-8The eighth categorical series slot (amber).
--color-chart-otherThe fold slot, worn once more than eight series show at once (neutral gray). Collapse the long tail into a single "Other" series.
--color-chart-decorativeThe neutral fill every series wears on a decorative chart, in place of its categorical slot.

The Bar Chart page records what each theme measures over those 28 pairs.

The color escape hatch accepts any CSS color string, but static colors (a raw hex) do not adapt across themes — prefer a var(...) reference to a theme-aware token, and validate custom palettes for surface contrast and for colorblind-safe separation from every other color on the chart, just like the defaults. A series that carries good/bad meaning — an error rate, a pass/fail split — should wear the semantic status colors (e.g. var(--color-danger-600)), never a categorical slot.

Series identity and color

Three public paths control a series identity or color.

  • Fixed identity — pass seriesSlot on LineChart.Line. It reserves one built-in color-and-shape pairing before automatic series take the remaining slots.
  • One color channel — pass color on LineChart.Line. It takes a chart token by name or any CSS color string without changing the slot or default shape.
  • One chart — redeclare the token on LineChart.Root: className="[--color-chart-1:var(--color-brand)]". Every series that lands in slot 1 picks the new value up. So do its legend key and hover dot.

The chart resolves every token from Root's computed style in one read, inside that Root's own cascade, so a declaration scoped to one Root wins for that chart alone. Sibling charts on the page keep the theme value. A bare currentColor resolves from Root's computed color. A value the canvas cannot parse by itself, such as light-dark() or a currentColor nested inside color-mix(), resolves through a hidden element inside Root instead. Declare the same custom property higher up (on a dashboard wrapper, or at :root) to retheme a whole subtree.

An overridden color leaves mantle's gates behind, so measure the value you set against the slots its neighbors hold. The example fixes Last week at slot 4 for a diamond identity, then overrides only its paint.

Line chart with 7 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { LineChart } from "@ngrok/mantle/line-chart"; const signupsData = [	{ day: "Mon", thisWeek: 42, lastWeek: 38 },	{ day: "Tue", thisWeek: 61, lastWeek: 47 },	{ day: "Wed", thisWeek: 55, lastWeek: 60 },	{ day: "Thu", thisWeek: 67, lastWeek: 52 },	{ day: "Fri", thisWeek: 74, lastWeek: 66 },	{ day: "Sat", thisWeek: 30, lastWeek: 34 },	{ day: "Sun", thisWeek: 26, lastWeek: 22 },]; function SeriesIdentityLineChartExample() {	return (		<LineChart.Root			data={signupsData}			xKey="day"			// An app declares its brand token once, high in the tree. This example			// declares it beside the override so it stands alone. A theme that needs			// its own value takes a variant: `dark:[--color-brand:…]`.			className="[--color-brand:#d07c00] [--color-chart-1:var(--color-brand)]"			aria-label="Signups by day of week"		>			<LineChart.Grid />			<LineChart.XAxis />			<LineChart.YAxis />			<LineChart.Line dataKey="thisWeek" label="This week" />			<LineChart.Line				dataKey="lastWeek"				label="Last week"				seriesSlot={4}				color="var(--color-accent-600)"			/>			<LineChart.Tooltip />			<LineChart.Legend />		</LineChart.Root>	);}