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.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/line-chart.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/line-chart.jsonbunx shadcn@latest add https://panelui.dev/r/web/line-chart.jsonThe 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,valueandcaption, 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.rowsis 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 laterLinedraws over an earlier one.LineChart.ReferenceLine— A rule across the plot at the valuey: a budget, an average, a target. It counts toward the value axis, andlabelwrites text above its left end.LineChart.Marker— A rule down the plot at the rowindex: a release, the day something ran out.labelwrites text beside its top.LineChart.Skeleton— A flat rule where the series will be, with a highlight moving along it, drawn only whilestatusisloading.LineChart.XAxis— The x labels, each on its own point.ticksis 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'slegendprop 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
| Prop | Type | Default | Description |
|---|---|---|---|
data | readonly LineChartDatum[] | — | The rows. Each one is a point along the x-axis. |
xDataKey | string | 'date' | Key holding the x label. Used by the axis and the readout. |
status | LineChartStatus | '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. |
aspectRatio | number | 2 | The plot's width ÷ height. 2 is the wide card shape; 1.6 suits a narrow column. |
animationDuration | number | 700 | Milliseconds for the reveal. |
domainDuration | number | 500 | Milliseconds 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. |
curve | LineChartCurve | '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. |
compact | boolean | false | Drop the axis padding so the line reaches the edges, for a sparkline. |
label | string | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 4 | Roughly how many lines to draw across the value axis. |
dashArray | string | '4 6' | The stroke's dash pattern. Omit for the default dash, "none" for a solid rule. |
opacity | number | 1 | Opacity of the lines. |
color | LineChartColor | — | A role in the theme, or any CSS colour, for the lines. Defaults to the border colour. |
LineChart.Line
| Prop | Type | Default | Description |
|---|---|---|---|
dataKey | string | — | Column in the data holding this series' values. A missing value leaves a gap. |
color | LineChartColor | 'chart-1' | A role in the theme, or any CSS colour, for the series. |
strokeWidth | number | 2.5 | The stroke's width. |
dashArray | string | — | The stroke's dash pattern, such as "6 4", for a projection or a secondary series. |
showMarkers | boolean | false | A dot at every point. Best kept for short series. |
curve | LineChartCurve | — | Defaults to the chart's curve. step beside a smooth series draws a balance against its plan. |
LineChart.Area
| Prop | Type | Default | Description |
|---|---|---|---|
dataKey | string | — | Column in the data holding this series' values. |
color | LineChartColor | 'chart-1' | A role in the theme, or any CSS colour, for the fill. |
opacity | number | 0.18 | Opacity at the line. It fades to nothing at the bottom of the plot. |
LineChart.ReferenceLine
| Prop | Type | Default | Description |
|---|---|---|---|
y | number | — | The value to draw the rule at. It counts toward the value axis. |
color | LineChartColor | 'foreground' | A role in the theme, or any CSS colour, for the rule and its label. |
dashArray | string | '3 4' | The stroke's dash pattern. |
label | string | — | Text above the rule's left end. |
LineChart.Marker
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | — | The row to draw the rule at, counted from 0. |
color | LineChartColor | 'muted-foreground' | A role in the theme, or any CSS colour, for the rule and its label. |
dashArray | string | '2 3' | The stroke's dash pattern. |
label | string | — | Text beside the rule's top. It moves to the left of a rule near the right edge. |
LineChart.Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 1400 | Milliseconds for one pass of the sweep. |
color | LineChartColor | — | A role in the theme, or any CSS colour, for the rule under the highlight. Defaults to the muted colour. |
LineChart.XAxis
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | 4 | How 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
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | 4 | Roughly 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
| Prop | Type | Default | Description |
|---|---|---|---|
color | LineChartColor | 'foreground' | The crosshair's colour: a role in the theme, or any CSS colour. |
showLabel | boolean | true | Draw 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. |
labels | Record<string, string> | — | Prettier names for the series keys, as the legend takes. |
className | string | — | Added to the readout's surface. |
LineChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
labels | Record<string, string> | — | Prettier names for the series keys. |
LineChart.Header
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Small line above the value: what the chart is of. |
value | ReactNode | — | The readout. The largest thing on the card, and the first thing read. |
caption | ReactNode | — | One muted line under the value: a period, a comparison, a total. |
labels | Record<string, string> | — | Prettier names for the series keys, as the legend takes. |
legend | boolean | false | Draw 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"