Line Chart
Token-driven line chart built on Recharts v3 — multi-series, step and curve interpolation, honest data gaps, y-axis domain control, threshold annotations, profit and loss colouring and full light/dark theming.
pnpm add @tessinaui/uiWhen to use
- A trend over a continuous axis. Time is the usual one. A line asks the reader to follow change, so use it when the shape of the movement is the point.
- A handful of series compared on one scale. Revenue against expenses, this
period against the last (draw the earlier one with
dashedSeries). - Data with honest gaps. A
nullbreaks the line instead of inventing a reading (Missing data). - A compact trend inside a card or table row. Switch the chrome off for the sparkline form.
- Values that sit far from zero. A line encodes change, so
autoMinValueorminValueis legitimate (Choosing a y-axis). - A result that changes sign. Profit and loss or cash flow, where the side of zero is the message (Profit and loss).
When not to use
- For categories. Counts per region, team or product are BarChart; a line implies the points between them are meaningful.
- To emphasise volume. A filled area under the line is AreaChart, not a prop here.
- For a share of a whole. Use PieChart.
- For a line read against bars. A total with its average or target, or a rate against the volume it comes from, is a ComposedChart.
- For one value against a target. Use Meter or Progress.
- For many series. Past the six series colours, group the rest into "other", split into small charts or toggle series on demand; more colours only make the lines harder to tell apart.
- When the exact values matter more than the shape. Put the numbers in a Table and keep the chart as the summary.
Installation
pnpm add @tessinaui/ui rechartsUsage
import { LineChart } from "@tessinaui/ui";
import type { ChartConfig } from "@tessinaui/ui";
const config: ChartConfig = {
revenue: { label: "Revenue", color: "var(--chart-1)" },
expenses: { label: "Expenses", color: "var(--chart-3)" },
};
const data = [
{ month: "Jan", revenue: 4200, expenses: 2400 },
{ month: "Feb", revenue: 3800, expenses: 2210 },
{ month: "Mar", revenue: 5100, expenses: 2290 },
];
<LineChart
data={data}
config={config}
categoryKey="month"
dashedSeries={["expenses"]}
label="Revenue outgrew expenses every month this quarter"
/>Examples
Default
A smooth, single-series line.
Multiple
Two series in brand tones; expenses dashed so it stays distinct without colour.
Curve
All six interpolations, including the three step alignments.
Sizes
Three aspect ratios — sm (short/wide), md, and lg (tall).
Dots and labels
Marker modes, and the three ways to print values on the plot.
Missing data
What a null does, and what connectNulls does to it.
Y-axis domain
Anchored at zero, fitted to the data, or pinned to a fixed window.
Axes
Titles, tick formatters, a right-hand axis, and end-only ticks.
Thresholds and bands
ReferenceLine and ReferenceArea passed as children.
Forecast
Actual and projected as two series, the projection dashed.
Period comparison
The previous period as a muted, dashed ghost.
Profit and loss
signColored colours one series by its sign: success above zero, error below,
split exactly where the line crosses zero. The zero line is drawn in the axis
ink and always stays in view. The tooltip's value leads with + or −, and its
indicator takes the reading's colour. Pointing at Profit or Loss in the legend
dims the other half.
Colour is never the only cue: a reading's side of the zero line and the sign in
the tooltip both carry it. On a coloured plate whose ink replaces both hues,
those are the only cues, so add dashedLoss there to dash the part below zero.
<LineChart
data={months}
config={{ net: { label: "Net result" } }}
categoryKey="month"
signColored
dashedLoss
valueFormatter={(value) => currency.format(value)}
/>Series toggling
Toggles outside the legend, as real aria-pressed buttons.
Sparkline
The same component with its chrome switched off.
In a card
Range chips and the value callout belong to the card, not the chart.
Choosing a day
With selectionMode="single" a click or a tap anywhere over a day, or Enter or
Space on the day the arrow keys are on, chooses it; choosing it again clears
it. The chosen period keeps the hover band, each line's dot on it wears a ring, and its tick becomes the inverse
chip, so the choice reads without colour. Here the
list below shows the chosen day's oldest open tickets.
On a coloured plate
Inside a coloured Surface the chart's chrome takes the plate's ink with no
prop: tick text, axis titles, grid lines, the hover cursor, the legend, printed
values and the focus ring. Series colours are yours. The --chart-N tokens are
measured against the page, not a plate, so give each series the plate's ink
(hsl(var(--surface-ink))) and tell series apart with dashedSeries and a
matching legend icon. The tooltip is a page panel, so it keeps the page's
look.
States
Loading skeleton, empty state and error.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, string | number | null>[] | — | Row-per-category data. |
config | ChartConfig | — | Series metadata (label, color). |
categoryKey | string | — | Datum key for the x-axis. |
series | string[] | all config keys | Which series to plot. |
curve | "monotone" | "natural" | "linear" | "step" | "stepBefore" | "stepAfter" | "monotone" | Interpolation. |
size | "sm" | "md" | "lg" | "md" | Aspect ratio. |
dots | "none" | "all" | "last" | "all" | Point markers. "last" marks each series' final reading. |
showLabels | boolean | "endpoints" | "extremes" | false | Print values on the plot. |
dashedSeries | string[] | [] | Series rendered with a dashed stroke. |
signColored | boolean | false | Colour the line by its sign, split at zero. Draws the first series only. |
dashedLoss | boolean | false | With signColored, dash the part below zero. |
valueFormatter | (value, seriesKey) => string | toLocaleString() | Format tooltip values. With signColored it receives the magnitude and the chart writes the sign. |
strings | Partial<LineChartStrings> | English, Arabic under dir="rtl" | The legend's Profit and Loss names, and selection's words: selected, cleared, items, period. |
connectNulls | boolean | false | Bridge null gaps with an interpolated segment. |
autoMinValue | boolean | false | Fit the y-axis to the data instead of anchoring at zero. |
minValue / maxValue | number | — | Pin either end of the y-axis. |
yAxisPosition | "left" | "right" | follows dir | Which side the y-axis sits on. |
xAxisLabel / yAxisLabel | string | — | Axis titles. |
xTickFormatter / yTickFormatter | (value, index) => string | — | Format tick text. |
startEndOnly | boolean | false | Render only the first and last x ticks. |
showGrid / showLegend / showXAxis / showYAxis / showTooltip | boolean | — | Toggle chart chrome. |
selectionMode | "none" | "single" | "none" | "single" lets a reader choose one period by click, tap, Enter or Space; choosing it again clears it. |
selected | string | null | — | The categoryKey value of the chosen period, controlled. null chooses nothing. |
defaultSelected | string | null | null | The categoryKey value of the period chosen at first render, uncontrolled. |
onSelectedChange | (key, { datum, index, source }) => void | — | Called on every choice and clear; source is "pointer" or "keyboard". |
active | string | null | — | The categoryKey value of the lit period, controlled: opens the tooltip on it. |
onActiveChange | (key: string | null) => void | — | Called when the chart's own pointer or arrow keys move to another period. |
children | ReactNode | — | Extra Recharts children (ReferenceLine, Brush, …). |
dir | "ltr" | "rtl" | inherited | Reading direction. Unset, read off the chart's position in the page once it mounts. |
label | string | — | Accessible name; renders the chart as a role="figure". |
description | string | — | Visually hidden summary for assistive tech. |
loading | boolean | false | Render the skeleton. |
error | ReactNode | — | Render an error message instead of the chart. |
emptyState | ReactNode | "No data to display" | Shown when data is empty. |
Missing data
A null leaves a gap, and that is the default. A gap is information: it says
nobody measured this point.
connectNulls bridges the gap by drawing a straight segment between the two
readings that surround it. That segment is an interpolation — a value nobody
recorded, drawn in the same stroke as the data. Turn it on only when a gap
genuinely means "unchanged since the last reading".
// Honest: Wednesday is missing, and the chart says so.
<LineChart data={readings} config={config} categoryKey="day" />
// Only when a gap really means "no change".
<LineChart data={readings} config={config} categoryKey="day" connectNulls />Choosing a y-axis
The axis is anchored at zero by default. autoMinValue fits it to the data
instead.
A truncated y-axis is legitimate on a line chart. A line encodes change, and a 4% movement on a zero-anchored axis is a flat line that tells the reader nothing. This is exactly the opposite of a bar chart, where length encodes magnitude and cropping the axis misstates the data.
Use minValue / maxValue when a chart must stay comparable across reloads, or
when the scale is externally defined (a 0–100 score, a target band).
Non-colour differentiation
Colour alone is unreliable — for colour-vision-deficient readers, in greyscale
print, and on a bad projector. LineChart gives you three encodings to layer on
top of hue:
- Markers —
dots="all"gives each line a dotted trail;dots="last"marks only the current reading. - Dashes —
dashedSeries={["expenses"]}renders named series with a dash pattern. This is also the convention for a projection or a previous period. - Direct labels —
showLabels="endpoints"or"extremes"prints values on the plot, so a series can be read without consulting the legend at all. - Sign — a
signColoredline is read against its zero line, its tooltip writes+or−, anddashedLossdashes the part below zero.
Every series stroke is 2px, which is the minimum width for a chart mark to stay
legible, and the --chart-* tokens are gated at 3:1 against the plot background.
Accessibility
VoiceOver + Safari: passed 2026-09-14. A static read gives "Visitors more than doubled between January and June, figure" and the summary. Tab lands on the plot, which VoiceOver reads after the figure name as "web application", and the arrow keys read each point through the tooltip ("Feb, Visitors, 1,450"). NVDA and JAWS are untested.
- Name the insight, not the anatomy.
labelbecomes the chart's accessible name and renders it as arole="figure". "Visitors doubled since March" is useful; "Line chart of visitors by month" repeats what the page already says. descriptionadds a visually hidden summary, so a static read carries the message without anyone having to interact with the plot.accessibilityLayeris on: the plot is keyboard-navigable point by point, and the tooltip is a polite live region.- Animation respects
prefers-reduced-motion: reduce. - With
selectionMode="single"the periods become a list to choose from. The chart's one tab stop is a namedlistbox("Visits this week: Periods"), each period anoptionwitharia-selectedand a name that carries its readings, and the period the arrow keys are on is itsaria-activedescendant. Enter or Space chooses that period, so Enter no longer toggles the tooltip;Escapestill hides it. A polite status region says "Tue selected" or "Selection cleared" once per change. The screen reader pass of this mode is still to be run.
Composition
children are rendered inside the chart, so anything Recharts draws is
available without a prop for each one — ReferenceLine, ReferenceArea,
ReferenceDot, Brush, LabelList.
It is also the escape hatch for a fully custom tooltip: switch the built-in one off and pass your own.
<LineChart data={data} config={config} categoryKey="month" showTooltip={false}>
<ChartTooltip content={<MyTooltip />} />
<ReferenceLine y={target} label="Target" />
</LineChart>Boundaries
- Range presets (1D/1W/1M/1Y), the big value callout, and a granularity switcher belong to the surrounding card, not to the chart. No surveyed design system ships them as chart props — see the "In a card" example.
- A filled area under the line is
AreaChart, not a prop here. - A single value against a target is
MeterorProgress. - Categorical comparison is
BarChart; a line implies a continuous axis.
Theming
| Element | Token |
|---|---|
| Series stroke / marker | --chart-1 … --chart-6 (via config[key].color) |
| Grid lines | muted-foreground at 20% |
| Axis tick labels and titles | muted-foreground |
| Printed value labels | foreground; on a coloured plate, --surface-ink |
| Tooltip surface | popover / popover-foreground / border |
Migration
connectNulls now defaults to false. A chart whose data contains null
previously drew straight through the gap; it now breaks the line there. Pass
connectNulls to restore the old rendering — but read "Missing data" above
first, because the bridged segment is a value nobody measured.
dir no longer defaults to "ltr". Unset, the chart reads the direction it
inherits from the page, so it mirrors on an RTL page. Pass dir="ltr" to keep
the old layout there.
showDots is deprecated in favour of dots. showDots keeps working for
one minor and warns once in development; dots wins if both are set.
| Before | After |
|---|---|
showDots | dots="all" (the default) |
showDots={false} | dots="none" |