Line Chart
Multi-series lines for continuous time-series – change over time, indexed comparisons, forecasts. The FT / John Burn-Murdoch canon: direct line-end labels instead of a legend, one emphasized series in the accent while the rest mute to grey (never a rainbow), faint gridlines (the deliberate, correct deviation from the no-gridline bar rule), honest gaps where data is missing, and a Y domain that reads change – not magnitude-from-zero.
Studio · Code, chart, settings
A three-panel workbench. Tweak any setting on the right – the multi-series chart in the middle updates live, and the code on the left regenerates, ready to paste into your app.
import { LineChart } from "@/components/charts/line-chart";
const data = [
{ name: "Revenue", data: [120, 138, 131, 159, 172, 188], color: "#F54900" },
{ name: "Costs", data: [98, 104, 109, 112, 118, 121], color: "#71717A" }
];
const labels = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN"];
export function Example() {
return (
<LineChart
data={data}
labels={labels}
height={240}
emphasisSeries="Revenue"
header={{ title: "Revenue vs costs", subtitle: "Last 6 months" }}
source="Brock Analytics, 2026"
onPointClick={(selection) => {
console.log("clicked", selection.series.name, selection.point);
}}
onPointHover={(selection) => {
// selection is null on mouse leave
setHover(selection);
}}
onPointFocus={(selection) => {
// Fires on keyboard navigation
announce(`${selection.series.name}: ${selection.point.y}`);
}}
exportable
exportFileName="revenue-vs-costs"
/>
);
}| X | Revenue | Costs |
|---|---|---|
| JAN | 120 | 98 |
| FEB | 138 | 104 |
| MAR | 131 | 109 |
| APR | 159 | 112 |
| MAY | 172 | 118 |
| JUN | 188 | 121 |
Installation
npx shadcn@latest add https://brockui.com/r/line-chartUsage
import { LineChart } from "@/components/charts/line-chart";
const data = [
{ name: "Revenue", data: [120, 138, 131, 159, 172, 188] },
{ name: "Costs", data: [98, 104, 109, 112, 118, 121] },
];
const labels = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN"];
export function Example() {
return (
<LineChart
data={data}
labels={labels}
height={240}
emphasisSeries="Revenue"
source="Brock Analytics, 2026"
/>
);
}
// Opinionated defaults — but every sub-component is yours to replace via the
// `slots` prop. The crosshair tooltip lists every series at the hovered x.
export function WithCustomTooltip() {
return (
<LineChart
data={data}
labels={labels}
slots={{
tooltip: ({ xLabel, points }) => (
<div className="border-2 border-brock-accent p-2">
<div className="font-mono text-[10px] uppercase">{xLabel}</div>
{points.map((p) => (
<div key={p.series} className="flex gap-2">
<span style={{ color: p.color }}>{p.series}</span>
<span className="font-mono tabular-nums">{p.formatted}</span>
</div>
))}
</div>
),
}}
/>
);
}Props
| Name | Type | Default | Description |
|---|---|---|---|
| data | number[] | LineChartDataPoint[] | LineChartSeries[] | — | Line data. Three forms: number[] (single series, paired with labels/x), LineChartDataPoint[] (single series, object form), or LineChartSeries[] (multiple series sharing one X domain). A null value (in number arrays or as y: null) renders a GAP – the line breaks rather than interpolating across missing data |
| labels | (string | number)[] | undefined | X-axis labels / positions, used when data is a single series of plain numbers or points without their own x. Strings → point scale; numbers → linear/time |
| x | (string | number)[] | undefined | Alias for labels. Reads naturally for a continuous X axis |
| height | number | 200 | Chart height in pixels for the plot + Y-axis area |
| trend | number | undefined | Decimal trend indicator e.g. 0.184 → ↗ +18.4%. Accent if positive, muted if negative. Rendered top-right above the chart |
| referenceLine | { value: number | { stat: 'mean' | 'median' }, label? } | undefined | Dashed HORIZONTAL reference line – a fixed value ('Target') or a statistic computed over the EMPHASIZED (or first) series ({ stat: 'mean' } / { stat: 'median' }). Stats auto-label Mean/Median. Participates in the Y domain so the line stays visible |
| source | string | undefined | Attribution line rendered below the chart (FT/Bloomberg pattern) |
| accent | string | var(--brock-accent) | Override the accent color (any CSS color or var). Defaults to --brock-accent. Paints the emphasized series + a positive trend |
| lineWidth | number | 1.75 | Line stroke width in pixels |
| curve | 'linear' | 'monotone' | 'linear' | Line interpolation. 'linear' is a straight polyline; 'monotone' smooths it without overshooting – the safe smoothing that never invents a peak between two points |
| markers | 'auto' | 'always' | 'none' | 'auto' | Marker (dot) policy. 'auto' shows dots only for sparse series (≤ ~20 points) or a lone point; 'always' / 'none' force the behavior |
| xScale | 'linear' | 'time' | 'point' | inferred | X-axis scale. Inferred when omitted: 'point' for string x, else 'linear'. 'time' parses timestamps / ISO date strings deterministically (never Date.now()) |
| yScale | 'linear' | 'log' | 'linear' | Y-axis scale. 'linear' (default) or 'log' (base-10, 1·2·5 ticks). Non-positive values are dropped on a log scale with a dev warning – they have no log position |
| gridlines | boolean | true | Light horizontal gridlines. Default true – the deliberate, correct deviation from the bar canon's no-gridline rule: Tufte/FT permit faint gridlines for line / time-series reading |
| legend | 'none' | 'direct' | 'top' | 'direct' (multi) | Where the series legend lives. 'direct' (default for multi-series) labels each line at its right end in the line's color – the FT signature, replaces a legend. 'top' renders a chip row above the chart; 'none' hides it |
| directLabels | boolean | legend === 'direct' | Direct line-end labels on/off. Defaults to true for multi-series (follows legend === 'direct'). Overlapping labels are nudged apart with a vertical collision-avoidance pass |
| directLabelValues | boolean | false | Append each series' last value to its direct label |
| emphasisSeries | string | undefined | Emphasize a single series by key or name: it takes the accent color at full weight while the others mute to grey. Per-series emphasis: true works too. When neither is set and there is one series, it is emphasized |
| lastValueDot | boolean | false | Draw an emphasized dot at each series' latest defined point – the FT 'where it ended' marker |
| yBaselineZero | boolean | false | Opt into a zero baseline in the Y domain. By default the Y domain is a 'nice' auto range NOT forced to zero – a line chart reads change, not magnitude-from-zero (the FT default). Set true for share/percent data |
| description | string | auto-generated | Accessible description for screen readers (figcaption + sr-only table). Defaults to an auto Amy-Cesal summary: 'Line chart with N series. SERIES: high … at …, low … at …' |
| yAxisFormat | (v: number) => string | toLocaleString | Custom formatter for Y-axis tick labels. Wins over numberFormat |
| formatValue | (v: number) => string | toLocaleString | Custom formatter for tooltip / direct-label / reference-line values. Wins over numberFormat |
| className | string | undefined | Pass-through className for the outer figure wrapper |
| header | { title?, subtitle? } | undefined | Title + subtitle block above the chart |
| xAxis | { title?, hideTicks?, ticks?, format? } | undefined | X-axis configuration: title (below ticks), hideTicks, approximate tick count, custom tick label formatter (receives the resolved numeric x) |
| yAxis | { title?, min?, max?, hideTicks?, ticks? } | undefined | Y-axis configuration. Unlike a column chart, a line chart's Y domain is a nice auto range (NOT zero-anchored) by default – change is the story. Force min / max, hide ticks, set approximate tick count, add a rotated title |
| numberFormat | { prefix?, suffix?, decimals?, locale?, notation?, style?, currency? } | undefined | Number formatter applied to Y-axis ticks, tooltip values, direct labels, and the reference line. Supports BCP-47 locale, Intl.NumberFormat notation ('compact' → 1.2K), style ('currency' / 'percent'), and ISO 4217 currency. Explicit formatValue/yAxisFormat win |
| animation | { enabled?, duration? } | { enabled: true, duration: 600 } | Line-draw / fade entry animation on mount. Disabled automatically when prefers-reduced-motion is set |
| events | LineChartEvent[] | undefined | Vertical event markers – thin dashed lines at x positions with optional rotated labels. The FT 'deployment / shock / announcement' call-out. Reproduced in the SVG / PNG export |
| bands | LineChartBand[] | undefined | Shaded x-range bands – low-opacity vertical zones (the FT recession-shading pattern). Sit behind the lines; reproduced in export |
| loading | boolean | false | Loading state. With no data → a full skeleton (solid placeholder line + an accessible loading label, ARIA role=status). With data → a dim overlay with a small spinner for background refresh. Honors prefers-reduced-motion |
| error | Error | string | null | null | Terminal error state. Replaces the chart even when data is present (stale data next to an error is misleading). Accepts an Error, a string message, or null. ARIA role=alert |
| onRetry | () => void | undefined | Callback for the retry button in the default error state. The button is rendered only when this prop is provided |
| loadingLabel | string | 'Loading…' | Accessible label for the skeleton (loading) state, used as its ARIA label. Override for localization |
| errorLabel | string | 'Error' | Label rendered above the error message and used as the ARIA label. Override for localization |
| retryLabel | string | 'Retry' | Label of the retry button in the default error state. Override for localization |
| loadingFallback | ReactNode | undefined | Full override of the default skeleton + overlay UI. Use for a custom-branded loading experience |
| errorFallback | ReactNode | (error: Error) => ReactNode | undefined | Full override of the default error UI. May be a React node or a function that receives the normalized Error |
| exportable | boolean | { png?, svg?, csv?, copy? } | false | Show the export toolbar (top-right). true = all 4 actions; object form = enable specific ones. Imperative ref methods always work regardless |
| exportFileName | string | (format) => string | 'chart' | Base file name for downloads. String for fixed, function for per-format. Right extension (.png/.svg/.csv) auto-appended |
| onExport | (format, artifact) => void | undefined | Fires after an export completes. Receives format ('png'|'svg'|'csv'|'copy') and the artifact (Blob for png/copy, string for svg/csv). Useful for analytics or custom share flows |
| ref | Ref<LineChartHandle> | — | Imperative API: { exportSVG, exportPNG, exportCSV, copyImage, focusPoint, getSelection }. Export methods work even in loading/error/empty. focusPoint(target) takes a point index (clamped) on the emphasized series OR a stable key string (unknown key → -1). getSelection() returns { series, point, index, key } or null |
| onPointClick | (selection, event) => void | undefined | Fires on click, tap, or Enter/Space on a focused point. Receives the full selection (series + point + index + key) and the originating event |
| onPointHover | (selection | null) => void | undefined | Fires on hover of the nearest x (selection is null on leave). Useful for syncing custom legend / detail panels with the hovered x |
| onPointFocus | (selection) => void | undefined | Fires when keyboard focus moves between points (Arrow / Home / End along x, Up / Down to switch series). Tracks the roving-tabindex position |
| slots | LineChartSlots | {} | Headless slot dictionary. Each slot replaces a default sub-component: tooltip (crosshair multi-series), empty (no data), loading (skeleton), error (terminal), toolbar (export chips), caption (below source), watermark (figure overlay). Each slot receives typed props. Slots win over loadingFallback / errorFallback shortcuts |
| caption | string | undefined | Short editorial caption – italic muted text with left border, rendered below the source line. FT/Stripe-Letters print-margin pattern. slots.caption wins over this |
| watermark | string | undefined | Diagonal watermark text – faint pixel-font overlay (DRAFT / CONFIDENTIAL) over the chart. A document-lifecycle marker, not branding (use source for attribution). Deliberately KEPT in print. slots.watermark wins over this |
| chartType | string | 'line' | Machine-readable identifier stamped on the figure as data-chart-type. Included in toJSON() output. AI/LLM tooling + analytics use it to reason about the chart shape |
| dataDescription | string | undefined | Natural-language description of the data ('Revenue vs costs, last 6 months'). Stamped as data-description. For AI prompts and editorial provenance. Distinct from `description` (which is the screen-reader auto-label) |
| data-testid | string | undefined | QA selector hook forwarded to the figure. Stable across className refactors – Testing Library / Playwright convention |
Accessibility
Built to WCAG 2.2 AA. Keyboard navigable, screen-reader friendly, honors prefers-reduced-motion.
| Tab | Move focus into the chart (single tab stop) |
| ← → | Move along x on the emphasized series (roving tabindex) |
| ↑ ↓ | Switch the focused series up / down |
| Home | Jump to the first point |
| End | Jump to the last point |
| Enter / Space | Invoke onPointClick on the focused point |
- ·
<figure role="figure">wraps the chart with anaria-labelledbypointing to the figcaption - · Each focusable point uses
aria-roledescription="data point"witharia-label="SERIES, X: value" - · A visually-hidden
<table class="sr-only">provides a tabular summary – one column per series, one row per x value (gaps render as “–”) - · Trend indicator gets a human-readable label (“Trend up 18.4 percent”) – arrows are aria-hidden
Design moves
- 1. Direct line-end labels replace a legend (the FT signature), in each line's color, with vertical collision avoidance.
- 2. Emphasis, not rainbow: one series in
--brock-accentat full weight, the rest muted to a restrained greyscale ramp. No N-color spaghetti. - 3. Faint horizontal gridlines – the deliberate, correct deviation from the bar canon's no-gridline rule. Tufte / FT permit them for time-series reading.
- 4. Honest gaps: a
y: nullpoint BREAKS the line into sub-paths – never silently interpolated across missing data. - 5. Y domain reads change, not magnitude – a 'nice' auto range NOT forced to zero by default (
yBaselineZeroopts in for share/percent data). - 6. Crosshair + multi-series tooltip snaps to the nearest x and lists every series' value there, sorted descending.
- 7. Editorial layers: vertical
eventsmarkers, shadedbands(recession shading), adashedseries for forecasts – all reproduced in the SVG export. - 8. A clear empty state (icon +
No datamessage) and a solid loading skeleton in the same Tufte-friendly visual language.
When to use
Line Chart fits continuous series where the X axis is ordered and the story is the trend: metrics over time (revenue, latency, active users), indexed comparisons across entities (countries, cohorts rebased to 100), and fact-vs-forecast (a dashed projection line). Multiple series share one Y domain so they read against each other; emphasis keeps one in focus while the rest provide context.
When not to use
For discrete ordered buckets where each value is independent (days of the week, channels) use Column Chart. For categorical ranking with long labels use Bar Chart. For tiny embedded charts inside metric cards or prose use Sparkline. For composition over time (parts of a whole) use Stacked Area / Stacked Column (coming soon).
Inspired by
- · John Burn-Murdoch, Financial Times – direct labels, emphasis over rainbow, indexed lines, log scale where it tells the truth
- · Financial Times Visual Journalism – sparse axes, source-line attribution, recession shading
- · Stripe Annual Letters – inline numerics in editorial flow
- · Edward Tufte, The Visual Display of Quantitative Information – data-ink discipline, sparklines, small multiples