Line Chart

A series over time, drawn in from the left.

'use client';

import { useState } from 'react';
import { Frame } from '@/components/panelui/frame';
import { LineChart, type LineChartDatum } from '@/components/panelui/line-chart';

const VISITS = [
  { date: 'Mar 1', visits: 1_240 },
  { date: 'Mar 8', visits: 1_480 },
  { date: 'Mar 15', visits: 1_390 },
  { date: 'Mar 22', visits: 1_720 },
  { date: 'Mar 29', visits: 1_660 },
  { date: 'Apr 5', visits: 2_040 },
  { date: 'Apr 12', visits: 1_910 },
  { date: 'Apr 19', visits: 2_380 },
  { date: 'Apr 26', visits: 2_610 },
];

const latest = VISITS[VISITS.length - 1];
const count = (value: number) => value.toLocaleString('en-US');

export default function LineChartOverview() {
  const [active, setActive] = useState<LineChartDatum | null>(null);

  return (
    <Frame variant="band" className="w-full max-w-lg">
      <Frame.Header>
        <Frame.Title>Weekly visits</Frame.Title>
        <Frame.Action>Drag to inspect</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="p-5">
        <LineChart data={VISITS} onActiveIndexChange={(_index, datum) => setActive(datum)}>
          <LineChart.Header
            value={count(Number((active ?? latest).visits))}
            caption={active ? `Week of ${active.date}` : 'Latest week'}
          />
          <LineChart.Grid />
          <LineChart.Area dataKey="visits" />
          <LineChart.Line dataKey="visits" />
          <LineChart.YAxis />
          <LineChart.XAxis />
          <LineChart.Tooltip showLabel={false} />
        </LineChart>
      </Frame.Panel>
    </Frame>
  );
}

Use it to show how a value moved over time: visits per week, response time per hour. Each row of data is a point, and each LineChart.Line is a series drawn from one of its keys.

The grid, the fill, the axes, the legend and the readout are separate parts, so a chart has only the ones you write.

The value axis fits the data and does not have to reach zero, so a small change stays visible. To compare sizes, use a bar chart.

Installation

npx shadcn@latest add https://panelui.dev/r/web/line-chart.json

The source is copied into components/panelui/, where you can edit it.

Usage

import { LineChart } from "@/components/panelui/line-chart"
<LineChart data={visits} xDataKey="date">
  <LineChart.Grid />
  <LineChart.Area dataKey="visits" />
  <LineChart.Line dataKey="visits" />
  <LineChart.XAxis />
  <LineChart.Tooltip />
</LineChart>

Composition

<LineChart data={…}>
  <LineChart.Header />
  <LineChart.Grid />
  <LineChart.Area dataKey="…" />
  <LineChart.Line dataKey="…" />
  <LineChart.ReferenceLine y={…} />
  <LineChart.Marker index={…} />
  <LineChart.Skeleton />
  <LineChart.XAxis />
  <LineChart.YAxis />
  <LineChart.Legend />
  <LineChart.Tooltip />
</LineChart>
  • LineChart.Header — The strip above the plot: title, value and caption, with the series key or your own control on the trailing side.
  • LineChart.Grid — Lines across the value axis, in the theme's border colour. rows is roughly how many: the axis picks round numbers near it.
  • LineChart.Area — The fill under a series, fading to nothing at the bottom of the plot. It always draws under the lines.
  • LineChart.Line — One series. Declare one per key. A later Line draws over an earlier one.
  • LineChart.ReferenceLine — A rule across the plot at the value y: a budget, an average, a target. It counts toward the value axis, and label writes text above its left end.
  • LineChart.Marker — A rule down the plot at the row index: a release, the day something ran out. label writes text beside its top.
  • LineChart.Skeleton — A flat rule where the series will be, with a highlight moving along it, drawn only while status is loading.
  • LineChart.XAxis — The x labels, each on its own point. ticks is how many, evenly spaced with both ends kept.
  • LineChart.YAxis — Value labels on the grid lines, in round numbers, with room made for them.
  • LineChart.Legend — A swatch and a name per line, over the top corner of the plot. On a chart with a header, use the header's legend prop instead.
  • LineChart.Tooltip — The readout, and what drives it. It adds the crosshair and a dot on every line, and it covers the plot, so the pointer can be anywhere. showLabel={false} drops the popover.

Examples

Two series

Declare a Line per series. They share one value axis, so they stay comparable. The header's legend prop names them.

'use client';

import { LineChart } from '@/components/panelui/line-chart';

const LATENCY = [
  { hour: '09:00', p50: 112, p95: 248 },
  { hour: '10:00', p50: 118, p95: 276 },
  { hour: '11:00', p50: 131, p95: 322 },
  { hour: '12:00', p50: 126, p95: 298 },
  { hour: '13:00', p50: 142, p95: 364 },
  { hour: '14:00', p50: 137, p95: 331 },
  { hour: '15:00', p50: 121, p95: 284 },
];
const NAMES = { p50: 'Median', p95: '95th percentile' };

export default function LineChartTwoSeries() {
  return (
    <LineChart data={LATENCY} xDataKey="hour" className="max-w-lg">
      <LineChart.Header title="Response time" value="137 ms" caption="Median at 14:00" labels={NAMES} legend />
      <LineChart.Grid />
      <LineChart.Line dataKey="p95" color="chart-3" />
      <LineChart.Line dataKey="p50" />
      <LineChart.YAxis format={(value) => `${value} ms`} />
      <LineChart.XAxis />
      <LineChart.Tooltip labels={NAMES} formatValue={(value) => `${value} ms`} />
    </LineChart>
  );
}

Actual and forecast

dashArray draws a projection, and showMarkers puts a dot on every point of a short series. Repeat the last actual value in the forecast's column so the two lines meet. curve="linear" joins the points straight.

'use client';

import { LineChart } from '@/components/panelui/line-chart';

const REVENUE = [
  { month: 'Jan', actual: 42 },
  { month: 'Feb', actual: 48 },
  { month: 'Mar', actual: 51 },
  { month: 'Apr', actual: 59, forecast: 59 },
  { month: 'May', forecast: 64 },
  { month: 'Jun', forecast: 72 },
];

export default function LineChartProjection() {
  return (
    <LineChart data={REVENUE} xDataKey="month" curve="linear" className="max-w-lg">
      <LineChart.Grid />
      <LineChart.Line dataKey="actual" showMarkers />
      <LineChart.Line dataKey="forecast" color="muted-foreground" dashArray="6 4" />
      <LineChart.YAxis format={(value) => `$${value}k`} />
      <LineChart.XAxis ticks={6} />
      <LineChart.Legend labels={{ actual: 'Actual', forecast: 'Forecast' }} />
      <LineChart.Tooltip
        labels={{ actual: 'Actual', forecast: 'Forecast' }}
        formatValue={(value) => `$${value}k`}
      />
    </LineChart>
  );
}

Steps against a plan

curve="step" on one Line holds each value until the next, for a balance that moves on the day money does. The other line keeps the chart's curve, so a smooth plan can sit beside it.

'use client';

import { LineChart } from '@/components/panelui/line-chart';

/** A month's budget: what is left moves when money is spent, and the plan falls evenly. */
const BUDGET = [
  { day: '1', left: 900, plan: 900 },
  { day: '5', left: 900, plan: 780 },
  { day: '10', left: 620, plan: 630 },
  { day: '15', left: 540, plan: 480 },
  { day: '20', left: 210, plan: 330 },
  { day: '25', left: 210, plan: 180 },
  { day: '30', left: 95, plan: 0 },
];
const NAMES = { left: 'Left', plan: 'Plan' };

const money = (value: number) => `$${value}`;

export default function LineChartSteps() {
  return (
    <LineChart data={BUDGET} xDataKey="day" curve="linear" yDomain={[0, 1000]} className="max-w-lg" label="Budget left against an even plan, by day">
      <LineChart.Header title="Food budget" value="$95 left" caption="$95 ahead of plan" labels={NAMES} legend />
      <LineChart.Grid />
      <LineChart.Line dataKey="plan" color="muted-foreground" strokeWidth={1.5} dashArray="6 4" />
      <LineChart.Line dataKey="left" curve="step" />
      <LineChart.YAxis format={money} />
      <LineChart.XAxis />
      <LineChart.Tooltip labels={NAMES} formatValue={money} />
    </LineChart>
  );
}

Marking a value and a row

ReferenceLine marks a value, such as an average, and Marker marks a row. The axis grows to include a reference line above or below the data.

'use client';

import { LineChart } from '@/components/panelui/line-chart';

const SPENDING = [
  { month: 'Jan', spent: 1_840 },
  { month: 'Feb', spent: 1_620 },
  { month: 'Mar', spent: 2_110 },
  { month: 'Apr', spent: 1_930 },
  { month: 'May', spent: 2_480 },
  { month: 'Jun', spent: 1_760 },
];

const money = (value: number) => `$${value.toLocaleString('en-US')}`;

export default function LineChartAnnotated() {
  return (
    <LineChart data={SPENDING} xDataKey="month" className="max-w-lg" label="Spending by month, with the average and a move marked">
      <LineChart.Grid />
      <LineChart.Line dataKey="spent" />
      <LineChart.ReferenceLine y={1_957} label="Average $1,957" />
      <LineChart.Marker index={4} label="Moved flat" />
      <LineChart.YAxis format={money} />
      <LineChart.XAxis ticks={6} />
      <LineChart.Tooltip labels={{ spent: 'Spent' }} formatValue={money} />
    </LineChart>
  );
}

Any colour

color takes a theme role or any CSS colour, for a chart whose colour comes from elsewhere in your app. A row can carry extra fields, and XAxis.format and Tooltip.formatX get the whole row, so here an estimated value says so.

'use client';

import { LineChart, type LineChartDatum } from '@/components/panelui/line-chart';

/** `exact` is 0 where the balance was worked out from a rate, not read from the account. */
const BALANCE = [
  { date: 'Mar 1', exact: 1, balance: 4_210 },
  { date: 'Mar 8', exact: 1, balance: 4_480 },
  { date: 'Mar 15', exact: 0, balance: 4_120 },
  { date: 'Mar 22', exact: 0, balance: 4_690 },
  { date: 'Mar 29', exact: 1, balance: 5_030 },
];

const money = (value: number) => `$${value.toLocaleString('en-US')}`;
const dateOf = (row: LineChartDatum) => (row.exact === 0 ? `${row.date}, estimated` : String(row.date));

export default function LineChartAnyColour() {
  return (
    <LineChart data={BALANCE} xDataKey="date" className="max-w-lg" label="Account balance by week">
      <LineChart.Grid />
      <LineChart.Area dataKey="balance" color="#0ea5e9" />
      <LineChart.Line dataKey="balance" color="#0ea5e9" />
      <LineChart.XAxis ticks={3} format={(row) => String(row.date)} />
      <LineChart.Tooltip color="#0ea5e9" labels={{ balance: 'Balance' }} formatValue={money} formatX={dateOf} />
    </LineChart>
  );
}

Missing values

A row with no number for a series leaves a gap in that line. Over a gap, the readout moves to the nearest point with a value.

'use client';

import { LineChart } from '@/components/panelui/line-chart';

const READINGS = [
  { day: 'Mon', temperature: 21.4 },
  { day: 'Tue', temperature: 22.1 },
  { day: 'Wed', temperature: 21.8 },
  { day: 'Thu', temperature: null },
  { day: 'Fri', temperature: null },
  { day: 'Sat', temperature: 23.5 },
  { day: 'Sun', temperature: 22.9 },
];

export default function LineChartGaps() {
  return (
    <LineChart data={READINGS} xDataKey="day" className="max-w-lg">
      <LineChart.Grid />
      <LineChart.Line dataKey="temperature" color="chart-2" />
      <LineChart.YAxis format={(value) => `${value}°`} />
      <LineChart.XAxis ticks={7} />
      <LineChart.Tooltip labels={{ temperature: 'Temperature' }} formatValue={(value) => `${value}°C`} />
    </LineChart>
  );
}

Sparkline

compact drops the axis padding so the line reaches the edges. With no Tooltip it does not take focus, so give it a label that says what the shape shows.

'use client';

import { LineChart } from '@/components/panelui/line-chart';
import { Stat } from '@/components/panelui/stat';

const SIGNUPS = [31, 34, 33, 38, 36, 41, 44, 42, 48, 53, 51, 58].map((value) => ({ signups: value }));

export default function LineChartSparkline() {
  return (
    <div className="flex w-full max-w-xs items-end justify-between gap-6">
      <Stat>
        <Stat.Label>Signups</Stat.Label>
        <Stat.Value>58</Stat.Value>
      </Stat>
      <LineChart data={SIGNUPS} compact aspectRatio={3} label="Signups rising from 31 to 58" className="w-32">
        <LineChart.Area dataKey="signups" color="success" />
        <LineChart.Line dataKey="signups" color="success" strokeWidth={2} />
      </LineChart>
    </div>
  );
}

Loading, then data

Add LineChart.Skeleton and drive status. The series draws in from the left when it turns ready, and again each time it goes back to loading and returns.

'use client';

import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { LineChart, type LineChartStatus } from '@/components/panelui/line-chart';

const VISITS = [
  { date: 'Mar', visits: 1_240 },
  { date: 'Apr', visits: 1_480 },
  { date: 'May', visits: 1_390 },
  { date: 'Jun', visits: 1_720 },
  { date: 'Jul', visits: 1_660 },
  { date: 'Aug', visits: 2_040 },
];

export default function LineChartLoading() {
  const [status, setStatus] = useState<LineChartStatus>('loading');

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <LineChart data={VISITS} status={status}>
        <LineChart.Grid />
        <LineChart.Skeleton />
        <LineChart.Area dataKey="visits" />
        <LineChart.Line dataKey="visits" />
        <LineChart.XAxis />
      </LineChart>
      <Button variant="outline" onClick={() => setStatus(status === 'loading' ? 'ready' : 'loading')}>
        {status === 'loading' ? 'Finish loading' : 'Load again'}
      </Button>
    </div>
  );
}

Changing data

When data changes, the points and the axis move to their new values over domainDuration, and the reveal does not play again. A ref on the chart has replay(), which runs it on demand.

'use client';

import { useRef, useState } from 'react';
import { Button } from '@/components/panelui/button';
import { LineChart, type LineChartHandle } from '@/components/panelui/line-chart';

const MONTHS = ['Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'];
const PERIODS = {
  'This year': [19_800, 24_600, 27_100, 25_300, 29_900, 32_400],
  'Last year': [9_200, 11_500, 10_400, 13_800, 12_900, 15_100],
};
type Period = keyof typeof PERIODS;

export default function LineChartChangingData() {
  const chart = useRef<LineChartHandle>(null);
  const [period, setPeriod] = useState<Period>('This year');
  const rows = MONTHS.map((month, index) => ({ month, revenue: PERIODS[period][index] }));

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <LineChart ref={chart} data={rows} xDataKey="month">
        <LineChart.Grid />
        <LineChart.Area dataKey="revenue" />
        <LineChart.Line dataKey="revenue" />
        <LineChart.YAxis />
        <LineChart.XAxis />
        <LineChart.Tooltip labels={{ revenue: period }} />
      </LineChart>
      <div className="flex gap-2">
        <Button variant="outline" onClick={() => setPeriod(period === 'This year' ? 'Last year' : 'This year')}>
          Show {period === 'This year' ? 'last year' : 'this year'}
        </Button>
        <Button variant="ghost" onClick={() => chart.current?.replay()}>
          Replay
        </Button>
      </div>
    </div>
  );
}

API Reference

LineChart

PropTypeDefaultDescription
datareadonly LineChartDatum[]—The rows. Each one is a point along the x-axis.
xDataKeystring'date'Key holding the x label. Used by the axis and the readout.
statusLineChartStatus'ready'loading holds the series back and draws them in when it turns ready. Add a LineChart.Skeleton for something to stand in the plot meanwhile.
aspectRationumber2The plot's width ÷ height. 2 is the wide card shape; 1.6 suits a narrow column.
animationDurationnumber700Milliseconds for the reveal.
domainDurationnumber500Milliseconds for the points and the axis to settle after the data changes.
yDomain[number, number]—Fix the value axis instead of deriving it from the data.
curveLineChartCurve'monotone'monotone never overshoots between points; linear joins them straight; step holds each value until the next, for a balance that moves on the day money does.
onActiveIndexChange(index: number, datum: LineChartDatum | null) => void—The point being read, and -1/null when there is none. Fires when the index changes, not per frame.
compactbooleanfalseDrop the axis padding so the line reaches the edges, for a sparkline.
labelstring—What a screen reader calls the chart.
labelForDatum(datum: LineChartDatum, index: number) => string—What a screen reader says for one row. Defaults to its keys and values.

LineChart.Grid

PropTypeDefaultDescription
rowsnumber4Roughly how many lines to draw across the value axis.
dashArraystring'4 6'The stroke's dash pattern. Omit for the default dash, "none" for a solid rule.
opacitynumber1Opacity of the lines.
colorLineChartColor—A role in the theme, or any CSS colour, for the lines. Defaults to the border colour.

LineChart.Line

PropTypeDefaultDescription
dataKeystring—Column in the data holding this series' values. A missing value leaves a gap.
colorLineChartColor'chart-1'A role in the theme, or any CSS colour, for the series.
strokeWidthnumber2.5The stroke's width.
dashArraystring—The stroke's dash pattern, such as "6 4", for a projection or a secondary series.
showMarkersbooleanfalseA dot at every point. Best kept for short series.
curveLineChartCurve—Defaults to the chart's curve. step beside a smooth series draws a balance against its plan.

LineChart.Area

PropTypeDefaultDescription
dataKeystring—Column in the data holding this series' values.
colorLineChartColor'chart-1'A role in the theme, or any CSS colour, for the fill.
opacitynumber0.18Opacity at the line. It fades to nothing at the bottom of the plot.

LineChart.ReferenceLine

PropTypeDefaultDescription
ynumber—The value to draw the rule at. It counts toward the value axis.
colorLineChartColor'foreground'A role in the theme, or any CSS colour, for the rule and its label.
dashArraystring'3 4'The stroke's dash pattern.
labelstring—Text above the rule's left end.

LineChart.Marker

PropTypeDefaultDescription
indexnumber—The row to draw the rule at, counted from 0.
colorLineChartColor'muted-foreground'A role in the theme, or any CSS colour, for the rule and its label.
dashArraystring'2 3'The stroke's dash pattern.
labelstring—Text beside the rule's top. It moves to the left of a rule near the right edge.

LineChart.Skeleton

PropTypeDefaultDescription
durationnumber1400Milliseconds for one pass of the sweep.
colorLineChartColor—A role in the theme, or any CSS colour, for the rule under the highlight. Defaults to the muted colour.

LineChart.XAxis

PropTypeDefaultDescription
ticksnumber4How many labels to show, evenly spaced with both ends kept.
format(datum: LineChartDatum, index: number) => string—Turn a row into its label. Defaults to the value at xDataKey.

LineChart.YAxis

PropTypeDefaultDescription
ticksnumber4Roughly how many labels to show. Give it the same number as Grid's rows.
format(value: number) => string—Format a value for its label. Defaults to a compact number.

LineChart.Tooltip

PropTypeDefaultDescription
colorLineChartColor'foreground'The crosshair's colour: a role in the theme, or any CSS colour.
showLabelbooleantrueDraw the popover. Without it the crosshair and dots still follow the reading, for a readout in the header.
formatValue(value: number, key: string) => string—Format one series' value. Defaults to a compact number.
formatX(datum: LineChartDatum) => string—Format the readout's heading from the row. Defaults to the value at xDataKey.
labelsRecord<string, string>—Prettier names for the series keys, as the legend takes.
classNamestring—Added to the readout's surface.

LineChart.Legend

PropTypeDefaultDescription
labelsRecord<string, string>—Prettier names for the series keys.

LineChart.Header

PropTypeDefaultDescription
titleReactNode—Small line above the value: what the chart is of.
valueReactNode—The readout. The largest thing on the card, and the first thing read.
captionReactNode—One muted line under the value: a period, a comparison, a total.
labelsRecord<string, string>—Prettier names for the series keys, as the legend takes.
legendbooleanfalseDraw a swatch and a name per series along the trailing edge. Prefer this to LineChart.Legend on a chart that has a header: the legend sits over the plot, in a corner the lines also want.

LineChart, LineChart.Header and LineChart.Legend also take the props of the element they render, and a className. LineChart.Tooltip puts its className on the readout. The other parts draw nothing themselves and take only the props listed.

Notes

Sizing

The chart fills its container's width and takes its height from aspectRatio, which applies to the plot alone, so a header does not shrink it. The plot draws in the browser after it has been measured, and the space is reserved until then, so it is not in server-rendered HTML.

The value axis

The derived axis spans every Line and Area, with some headroom, rounded to numbers worth labelling. yDomain fixes it, which is the way to hold it steady across charts or to bring zero in.

Bundle size

The chart adds about 65 KB of gzipped JavaScript to a page that uses it. A page that does not import it pays nothing, including one that imports other components from the package root.

Colour

color on any part takes a role in the theme or any CSS colour. A role follows light and dark on its own, and a CSS colour stays as you give it. Every series defaults to chart-1, so give the others their own.

Reading a point

With a Tooltip, hovering the plot reads the nearest point. A click or a tap pins the reading until the next press elsewhere, or Escape. On a touch screen a sideways drag reads the points it crosses, and a vertical drag scrolls the page.

onActiveIndexChange fires when the point changes, not on every frame, so it can drive a readout outside the chart, as the first example does. Inside the chart, useLineChart gives the same point.

Keyboard

With a Tooltip, the plot takes focus. The arrow keys move between points, Home and End go to the first and last, Enter or Space pins the reading, and Escape clears it.

Screen readers

Each row is given as text beside the drawing. label names the chart and labelForDatum writes a row, for values with a unit. Without them a row is read as its keys and values.

Reduced motion

With reduced motion set, the series and the axis appear at their final values, and the skeleton's highlight is not drawn.

Server Components

LineChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read LineChart.Header off the import, so each part is also exported by name: LineChartHeader is LineChart.Header, and so on for the others.

import { LineChart, LineChartHeader, LineChartGrid, LineChartArea, LineChartLine, LineChartReferenceLine, LineChartMarker, LineChartSkeleton, LineChartXAxis, LineChartYAxis, LineChartTooltip, LineChartLegend } from "@/components/panelui/line-chart"

On this page