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
BarChartto compare discrete categories: bars encode value by length from a zero baseline. - Prefer
AreaChartwhen 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Composition
Compose the parts of a LineChart together to build your own:
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:
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:
Data attributes
LineChart.Line
One line series. Renderless — it registers the series with the chart, which paints it on canvas. Compose one Line per series.
LineChart.Grid
Hairline gridlines behind the lines. Renderless — painted on canvas. Omit the part to omit the grid.
LineChart.XAxis
Tick labels along the bottom of the plot. Renderless — painted on canvas. Labels that would collide are skipped, never rotated.
LineChart.YAxis
Value tick labels along the left of the plot. Renderless — painted on canvas. Ticks land on clean thousands-separated numbers.
LineChart.ReferenceLine
A dashed horizontal marker at a y value — an SLO threshold, a budget, a limit. Renderless — painted on canvas.
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:
Data attributes
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:
Data attributes
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:
While a copy is fresh, an always-mounted role="status" element beside the button announces "Copied" to a screen reader.
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.
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
seriesSlotonLineChart.Line. It reserves one built-in color-and-shape pairing before automatic series take the remaining slots. - One color channel — pass
coloronLineChart.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.