Charts · Line Chart

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.

Code
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"
    />
  );
}
Chart
Revenue vs costs
Last 6 months
Source: Brock Analytics, 2026
Line chart with 2 series. Revenue: high 188 at JUN, low 120 at JAN; Costs: high 121 at JUN, low 98 at JAN. Source: Brock Analytics, 2026.
Data table.
XRevenueCosts
JAN12098
FEB138104
MAR131109
APR159112
MAY172118
JUN188121

Installation

npx shadcn@latest add https://brockui.com/r/line-chart

Usage

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

NameTypeDefaultDescription
datanumber[] | 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)[]undefinedX-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)[]undefinedAlias for labels. Reads naturally for a continuous X axis
heightnumber200Chart height in pixels for the plot + Y-axis area
trendnumberundefinedDecimal 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? }undefinedDashed 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
sourcestringundefinedAttribution line rendered below the chart (FT/Bloomberg pattern)
accentstringvar(--brock-accent)Override the accent color (any CSS color or var). Defaults to --brock-accent. Paints the emphasized series + a positive trend
lineWidthnumber1.75Line 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'inferredX-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
gridlinesbooleantrueLight 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
directLabelsbooleanlegend === '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
directLabelValuesbooleanfalseAppend each series' last value to its direct label
emphasisSeriesstringundefinedEmphasize 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
lastValueDotbooleanfalseDraw an emphasized dot at each series' latest defined point – the FT 'where it ended' marker
yBaselineZerobooleanfalseOpt 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
descriptionstringauto-generatedAccessible 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) => stringtoLocaleStringCustom formatter for Y-axis tick labels. Wins over numberFormat
formatValue(v: number) => stringtoLocaleStringCustom formatter for tooltip / direct-label / reference-line values. Wins over numberFormat
classNamestringundefinedPass-through className for the outer figure wrapper
header{ title?, subtitle? }undefinedTitle + subtitle block above the chart
xAxis{ title?, hideTicks?, ticks?, format? }undefinedX-axis configuration: title (below ticks), hideTicks, approximate tick count, custom tick label formatter (receives the resolved numeric x)
yAxis{ title?, min?, max?, hideTicks?, ticks? }undefinedY-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? }undefinedNumber 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
eventsLineChartEvent[]undefinedVertical 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
bandsLineChartBand[]undefinedShaded x-range bands – low-opacity vertical zones (the FT recession-shading pattern). Sit behind the lines; reproduced in export
loadingbooleanfalseLoading 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
errorError | string | nullnullTerminal 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() => voidundefinedCallback for the retry button in the default error state. The button is rendered only when this prop is provided
loadingLabelstring'Loading…'Accessible label for the skeleton (loading) state, used as its ARIA label. Override for localization
errorLabelstring'Error'Label rendered above the error message and used as the ARIA label. Override for localization
retryLabelstring'Retry'Label of the retry button in the default error state. Override for localization
loadingFallbackReactNodeundefinedFull override of the default skeleton + overlay UI. Use for a custom-branded loading experience
errorFallbackReactNode | (error: Error) => ReactNodeundefinedFull override of the default error UI. May be a React node or a function that receives the normalized Error
exportableboolean | { png?, svg?, csv?, copy? }falseShow the export toolbar (top-right). true = all 4 actions; object form = enable specific ones. Imperative ref methods always work regardless
exportFileNamestring | (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) => voidundefinedFires 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
refRef<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) => voidundefinedFires 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) => voidundefinedFires on hover of the nearest x (selection is null on leave). Useful for syncing custom legend / detail panels with the hovered x
onPointFocus(selection) => voidundefinedFires when keyboard focus moves between points (Arrow / Home / End along x, Up / Down to switch series). Tracks the roving-tabindex position
slotsLineChartSlots{}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
captionstringundefinedShort editorial caption – italic muted text with left border, rendered below the source line. FT/Stripe-Letters print-margin pattern. slots.caption wins over this
watermarkstringundefinedDiagonal 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
chartTypestring'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
dataDescriptionstringundefinedNatural-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-testidstringundefinedQA 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.

Keyboard
TabMove focus into the chart (single tab stop)
← →Move along x on the emphasized series (roving tabindex)
↑ ↓Switch the focused series up / down
HomeJump to the first point
EndJump to the last point
Enter / SpaceInvoke onPointClick on the focused point
Screen reader markup
  • · <figure role="figure"> wraps the chart with an aria-labelledby pointing to the figcaption
  • · Each focusable point uses aria-roledescription="data point" with aria-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. 1. Direct line-end labels replace a legend (the FT signature), in each line's color, with vertical collision avoidance.
  2. 2. Emphasis, not rainbow: one series in --brock-accent at full weight, the rest muted to a restrained greyscale ramp. No N-color spaghetti.
  3. 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. 4. Honest gaps: a y: null point BREAKS the line into sub-paths – never silently interpolated across missing data.
  5. 5. Y domain reads change, not magnitude – a 'nice' auto range NOT forced to zero by default (yBaselineZero opts in for share/percent data).
  6. 6. Crosshair + multi-series tooltip snaps to the nearest x and lists every series' value there, sorted descending.
  7. 7. Editorial layers: vertical events markers, shaded bands (recession shading), a dashed series for forecasts – all reproduced in the SVG export.
  8. 8. A clear empty state (icon + No data message) 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