Charts and tablesLine chart
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callLine chart
Two series over time with a hover readout and toggles. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add line-chartReact
npx designme add line-chart --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A line chart that compares a main series with one or more reference series over time, such as this year against last year. A big number above it counts up to the latest value, and a readout follows the pointer to show the value at each point. In an agent product it suits a usage or growth metric on a dashboard.
- When you want to show a metric's trend over time next to the same period before.
- When the latest value matters most and should be the headline.
- When people need to read exact values by hovering, without a full analytics tool.
- The first entry in
seriesdraws as a solid line with a soft fill; the others draw dashed for comparison. - The lines draw in from the left while the headline number counts up to the last value.
- Moving the pointer shows a guide line, dots on each series and a readout with the value, the point's name from
points, and, withcompareLabel, the percent change between the first two series. - Legend buttons toggle each series on and off; one series always stays on.
rangefixes the bottom and top values,axissets the labels along the bottom, andformatandunitcontrol how values read.
- Each legend button uses aria-pressed to show whether its series is visible.
- The SVG drawing is aria-hidden, so the headline number and legend are what a screen reader gets.
- The count-up of the headline number finishes at once when the user prefers reduced motion.
Usage
Import it and drive it from your own state.
Import
import { LineChart } from '@/components/designme/line-chart/LineChart';Example
The demo above as a React component, 18 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: active editors this year against last year.
import { LineChart } from './LineChart';
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function LineChartDemo() {
return (
<div style={{ width: '100%' }}>
<LineChart label="Active editors" unit="editors" compareLabel="vs last year" points={MONTHS} range={[500, 1700]}
series={[
{ key: 'now', label: 'This year', values: [820, 860, 910, 980, 1040, 1130, 1210, 1180, 1320, 1460, 1540, 1620] },
{ key: 'before', label: 'Last year', values: [610, 640, 660, 700, 690, 760, 800, 790, 850, 880, 910, 960] }
]} />
</div>
);
}
Props
Everything LineChart takes.
| Prop | Type | What it does |
|---|---|---|
labelrequired | string | The name over the big number, like "Active editors" |
seriesrequired | LineChartSeries[] | The first series is the main one: a solid line with a soft fill. The others are dashed, to compare against |
pointsrequired | string[] | One name per point, shown in the hover readout, like the months |
axis | string[] | Labels spread along the bottom. Defaults to every other point |
range | [number, number] | The values at the bottom and top of the chart. Defaults to the data's range with a little room |
unit | string | What the values count, like "editors" in "1,620 editors" |
compareLabel | string | Ends the readout's comparison of the first two series, like "vs last year" in "+69% vs last year" |
format | (n: number) => string | Writes a value. Defaults to whole numbers like 1,620 |
visible | string[] | Keys of the series shown. Leave it out to let the legend toggle them. One always stays on |
defaultVisible | string[] | |
onVisibleChange | (keys: string[]) => void | |
className | string |
Code
The full source, as the CLI installs it.
LineChart.tsxReact · 129 lines
'use client';
import { useId, useState, type PointerEvent } from 'react';
import { formatCount } from '../lib/chart';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import '../designme.css';
import './line-chart.css';
export interface LineChartSeries {
/** Names the series in `visible` */
key: string;
/** The legend's label, like "This year" */
label: string;
/** One value per point, oldest first */
values: number[];
/** Defaults to the accent for the first series and a quiet grey for the others */
color?: string;
}
export interface LineChartProps {
/** The name over the big number, like "Active editors" */
label: string;
/** The first series is the main one: a solid line with a soft fill. The others are dashed, to compare against */
series: LineChartSeries[];
/** One name per point, shown in the hover readout, like the months */
points: string[];
/** Labels spread along the bottom. Defaults to every other point */
axis?: string[];
/** The values at the bottom and top of the chart. Defaults to the data's range with a little room */
range?: [number, number];
/** What the values count, like "editors" in "1,620 editors" */
unit?: string;
/** Ends the readout's comparison of the first two series, like "vs last year" in "+69% vs last year" */
compareLabel?: string;
/** Writes a value. Defaults to whole numbers like 1,620 */
format?: (n: number) => string;
/** Keys of the series shown. Leave it out to let the legend toggle them. One always stays on */
visible?: string[];
defaultVisible?: string[];
onVisibleChange?: (keys: string[]) => void;
className?: string;
}
const W = 320, H = 140;
// a smooth line through the points, eased halfway between each pair, as the site draws it
const smooth = (p: [number, number][]) => p.map((q, i) => {
if (!i) return `M${q[0]} ${q[1]}`;
const a = p[i - 1], m = (q[0] - a[0]) / 2;
return `C${a[0] + m} ${a[1]} ${q[0] - m} ${q[1]} ${q[0]} ${q[1]}`;
}).join(' ');
/** Two series over time, drawn in from the left, with a readout that follows the pointer and a legend that toggles them. */
export function LineChart({ label, series, points, axis, range, unit, compareLabel, format = formatCount, visible, defaultVisible, onVisibleChange, className }: LineChartProps) {
const [on, setOn] = useControllable(visible, defaultVisible ?? series.map(s => s.key), onVisibleChange);
const shown = series.map(s => on.includes(s.key));
const n = points.length, last = Math.max(1, n - 1);
const all = series.flatMap(s => s.values), min = Math.min(...all), max = Math.max(...all);
const [lo, hi] = range ?? [min - (max - min) * .11, max + (max - min) * .08];
const y = (d: number) => 136 - (d - lo) / (hi - lo || 1) * 128;
const paths = series.map(s => s.values.map((d, i): [number, number] => [i / last * W, y(d)]));
const color = (k: number) => series[k].color ?? (k ? 'var(--dm-muted)' : 'var(--dm-accent)');
// the lines draw in from the left while the big number counts up
const clip = useTween(W, 1400, 0);
const lead = Math.max(0, shown.indexOf(true));
const counted = useTween(series[lead]?.values[n - 1] ?? 0, 1400, 0);
const [at, setAt] = useState<{ i: number; w: number; h: number } | null>(null);
const [inside, setInside] = useState(false);
const move = (e: PointerEvent<HTMLDivElement>) => {
const b = e.currentTarget.getBoundingClientRect();
setAt({ i: Math.max(0, Math.min(last, Math.round((e.clientX - b.left) / b.width * last))), w: b.width, h: b.height });
setInside(true);
};
const toggle = (k: number) => {
if (shown[k] && shown.filter(Boolean).length === 1) return;
setOn(series.filter((_, j) => (j === k ? !shown[k] : shown[j])).map(s => s.key));
};
const ids = useId().replace(/[^\w-]/g, '');
const px = at ? at.i / last * at.w : 0;
const value = inside && at ? series[lead].values[at.i] : counted;
const pct = at && series[1] ? Math.round((series[0].values[at.i] / series[1].values[at.i] - 1) * 100) : 0;
const ticks = axis ?? points.filter((_, i) => i % 2 === 0);
return (
<div data-dm="" className={cx('dm-lnx dm-panel', className)}>
<div className="dm-lnx-h">
<div><span className="dm-muted">{label}</span><b className="dm-num dm-lnx-v">{format(value)}</b></div>
<div className="dm-lnx-lg">
{series.map((s, k) => (
<button key={s.key} type="button" className={cx('dm-lnx-k', shown[k] && 'dm-on')} aria-pressed={shown[k]} onClick={() => toggle(k)}>
<i style={{ background: color(k) }} />{s.label}
</button>
))}
</div>
</div>
<div className="dm-lnx-ch" onPointerMove={move} onPointerLeave={() => setInside(false)}>
<svg className="dm-lnx-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id={ids + 'g'} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" style={{ stopColor: color(0), stopOpacity: .16 }} />
<stop offset="1" style={{ stopColor: color(0), stopOpacity: 0 }} />
</linearGradient>
<clipPath id={ids + 'c'}><rect x="0" y="-10" width={clip} height="160" /></clipPath>
</defs>
{[35, 70, 105].map(g => <line key={g} className="dm-gl" x1="0" x2={W} y1={g} y2={g} />)}
<g clipPath={`url(#${ids}c)`}>
<path className="dm-ar" d={`${smooth(paths[0])} L${W} ${H} L0 ${H} Z`} fill={`url(#${ids}g)`} style={{ opacity: shown[0] ? 1 : 0 }} />
{/* the main line goes on top */}
{[...series.keys()].slice(1).reverse().concat(0).map(k => (
<path key={series[k].key} className={cx('dm-ln', !shown[k] && 'dm-off')} d={smooth(paths[k])} style={{ stroke: color(k) }} strokeDasharray={k ? '4 4' : undefined} />
))}
</g>
</svg>
<div className="dm-lnx-x" style={at ? { left: px } : undefined} />
<div className="dm-lnx-tt" style={at ? { left: Math.max(50, Math.min(px, at.w - 50)) } : undefined}>
<b className="dm-num">{at && `${format(series[lead].values[at.i])}${unit ? ' ' + unit : ''}`}</b>
<span>{at && points[at.i] + (compareLabel && shown[0] && shown[1] ? `, ${pct < 0 ? '−' : '+'}${Math.abs(pct)}% ${compareLabel}` : '')}</span>
</div>
{series.map((s, k) => (
<span key={s.key} className={cx('dm-lnx-pt', shown[k] && 'dm-on')} style={{ color: color(k), ...(at && { left: px, top: paths[k][at.i][1] / H * at.h }) }} />
))}
</div>
<div className="dm-lnx-ax dm-muted dm-num">{ticks.map((t, i) => <span key={i}>{t}</span>)}</div>
</div>
);
}
line-chart.cssReact · 27 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Line chart ---------- */
.dm-lnx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:10px}
.dm-lnx-h{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap}
.dm-lnx-h>div:first-child{display:flex;flex-direction:column;font-size:12.5px}
.dm-lnx-v{font-size:14px;font-weight:500;letter-spacing:-.02em;color:var(--dm-fg)}
.dm-lnx-lg{display:flex;gap:var(--dm-s-1)}
.dm-lnx-k{display:flex;align-items:center;gap:6px;height:26px;padding:0 9px;border-radius:var(--dm-r-pill);font-size:12px;color:var(--dm-muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.dm-lnx-k:hover{background:var(--dm-row)}}
.dm-lnx-k.dm-on{color:var(--dm-fg)}
.dm-lnx-k i{width:8px;height:8px;border-radius:50%;transition:transform .3s var(--dm-ease),opacity .3s}
.dm-lnx-k:not(.dm-on) i{opacity:.3;transform:scale(.6)}
.dm-lnx-ch{position:relative;height:150px}
.dm-lnx-svg{width:100%;height:100%;display:block;overflow:visible}
.dm-lnx-svg .dm-gl{stroke:var(--dm-line);stroke-width:1;vector-effect:non-scaling-stroke}
.dm-lnx-svg .dm-ln{fill:none;stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round;transition:opacity .4s}
.dm-lnx-svg .dm-ln.dm-off{opacity:0}
.dm-lnx-svg .dm-ar{transition:opacity .4s}
.dm-lnx-x{position:absolute;top:0;bottom:0;width:1px;background:var(--dm-faint);opacity:0;transition:opacity .2s;pointer-events:none}
.dm-lnx-tt{position:absolute;top:-6px;padding:6px 9px;border-radius:10px;background:var(--dm-ink);color:var(--dm-on-ink);font-size:11.5px;display:flex;flex-direction:column;opacity:0;transform:translate(-50%,-100%) translateY(4px);transition:opacity .2s,transform .25s var(--dm-ease);pointer-events:none;white-space:nowrap}
.dm-lnx-tt span{opacity:.7}
@media (hover:hover) and (pointer:fine){.dm-lnx-ch:hover .dm-lnx-x,.dm-lnx-ch:hover .dm-lnx-tt{opacity:1}}
@media (hover:hover) and (pointer:fine){.dm-lnx-ch:hover .dm-lnx-tt{transform:translate(-50%,-100%)}}
.dm-lnx-pt{position:absolute;width:9px;height:9px;margin:-4.5px;border-radius:50%;background:var(--dm-surface);box-shadow:0 0 0 2px currentColor;opacity:0;transition:opacity .2s;pointer-events:none}
@media (hover:hover) and (pointer:fine){.dm-lnx-ch:hover .dm-lnx-pt.dm-on{opacity:1}}
.dm-lnx-ax{display:flex;justify-content:space-between;font-size:11px}
line-chart.htmlHTML · 10 lines
<div class="card" data-c="line-chart">
<div class="stage-in">
<div class="lnx panel">
<div class="lnx-h"><div><span class="muted">Active editors</span><b class="num lnx-v">0</b></div><div class="lnx-lg"><button class="lnx-k on" data-s="0"><i style="background:var(--accent-mark,var(--accent))"></i>This year</button><button class="lnx-k on" data-s="1"><i style="background:var(--muted)"></i>Last year</button></div></div>
<div class="lnx-ch"><svg class="lnx-svg" viewBox="0 0 320 140" preserveAspectRatio="none"></svg><div class="lnx-x"></div><div class="lnx-tt"><b class="num"></b><span></span></div></div>
<div class="lnx-ax muted num"><span>Jan</span><span>Mar</span><span>May</span><span>Jul</span><span>Sep</span><span>Nov</span></div>
</div>
</div>
</div>
line-chart.cssHTML · 26 lines
/* ---------- Line chart ---------- */
.lnx{padding:var(--s-4);display:flex;flex-direction:column;gap:10px}
.lnx-h{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap}
.lnx-h>div:first-child{display:flex;flex-direction:column;font-size:12.5px}
.lnx-v{font-size:14px;font-weight:500;letter-spacing:-.02em;color:var(--fg)}
.lnx-lg{display:flex;gap:var(--s-1)}
.lnx-k{display:flex;align-items:center;gap:6px;height:26px;padding:0 9px;border-radius:var(--r-pill);font-size:12px;color:var(--muted);transition:color .2s,background .2s}
@media (hover:hover) and (pointer:fine){.lnx-k:hover{background:var(--row)}}
.lnx-k.on{color:var(--fg)}
.lnx-k i{width:8px;height:8px;border-radius:50%;transition:transform .3s var(--ease),opacity .3s}
.lnx-k:not(.on) i{opacity:.3;transform:scale(.6)}
.lnx-ch{position:relative;height:150px}
.lnx-svg{width:100%;height:100%;display:block;overflow:visible}
.lnx-svg .gl{stroke:var(--line);stroke-width:1;vector-effect:non-scaling-stroke}
.lnx-svg .ln{fill:none;stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round;transition:opacity .4s}
.lnx-svg .ln.off{opacity:0}
.lnx-svg .ar{transition:opacity .4s}
.lnx-x{position:absolute;top:0;bottom:0;width:1px;background:var(--faint);opacity:0;transition:opacity .2s;pointer-events:none}
.lnx-tt{position:absolute;top:-6px;padding:6px 9px;border-radius:10px;background:var(--ink);color:var(--on-ink);font-size:11.5px;display:flex;flex-direction:column;opacity:0;transform:translate(-50%,-100%) translateY(4px);transition:opacity .2s,transform .25s var(--ease);pointer-events:none;white-space:nowrap}
.lnx-tt span{opacity:.7}
@media (hover:hover) and (pointer:fine){.lnx-ch:hover .lnx-x,.lnx-ch:hover .lnx-tt{opacity:1}}
@media (hover:hover) and (pointer:fine){.lnx-ch:hover .lnx-tt{transform:translate(-50%,-100%)}}
.lnx-pt{position:absolute;width:9px;height:9px;margin:-4.5px;border-radius:50%;background:var(--surface);box-shadow:0 0 0 2px currentColor;opacity:0;transition:opacity .2s;pointer-events:none}
@media (hover:hover) and (pointer:fine){.lnx-ch:hover .lnx-pt.on{opacity:1}}
.lnx-ax{display:flex;justify-content:space-between;font-size:11px}
line-chart.jsHTML · 29 lines
DM.def('line-chart', (r, c) => {
const A = [820,860,910,980,1040,1130,1210,1180,1320,1460,1540,1620], B = [610,640,660,700,690,760,800,790,850,880,910,960];
const MO = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const svg = $('.lnx-svg', r), ch = $('.lnx-ch', r), x = $('.lnx-x', r), tt = $('.lnx-tt', r), v = $('.lnx-v', r);
const lo = 500, hi = 1700, P = s => s.map((d, i) => [i / 11 * 320, 136 - (d - lo) / (hi - lo) * 128]);
const smooth = p => p.map((q, i) => { if (!i) return `M${q[0]} ${q[1]}`; const a = p[i - 1], m = (q[0] - a[0]) / 2; return `C${a[0] + m} ${a[1]} ${q[0] - m} ${q[1]} ${q[0]} ${q[1]}`; }).join(' ');
const pa = P(A), pb = P(B);
svg.innerHTML = `<defs><linearGradient id="lng" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="var(--accent-mark,var(--accent))" stop-opacity=".16"/><stop offset="1" stop-color="var(--accent-mark,var(--accent))" stop-opacity="0"/></linearGradient><clipPath id="lnc"><rect class="lnr" x="0" y="-10" width="0" height="160"/></clipPath></defs>` +
[35, 70, 105].map(y => `<line class="gl" x1="0" x2="320" y1="${y}" y2="${y}"/>`).join('') +
`<g clip-path="url(#lnc)"><path class="ar" d="${smooth(pa)} L320 140 L0 140 Z" fill="url(#lng)"/><path class="ln l1" d="${smooth(pb)}" stroke="var(--muted)" stroke-dasharray="4 4"/><path class="ln l0" d="${smooth(pa)}" stroke="var(--accent-mark,var(--accent))"/></g>`;
ch.insertAdjacentHTML('beforeend', '<span class="lnx-pt on" style="color:var(--accent-mark,var(--accent))"></span><span class="lnx-pt on" style="color:var(--muted)"></span>');
const pts = $$('.lnx-pt', ch), on = [true, true];
// the line draws and the number counts once the chart has risen into view, so the draw is seen from its start
v.textContent = '0';
c.later(260, () => {
tween(0, 320, 1300, w => $('.lnr', svg).setAttribute('width', w), t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
tween(0, A[11], 1300, n => v.textContent = Math.round(n).toLocaleString('en-US'));
});
c.on(ch, 'pointermove', e => { const b = ch.getBoundingClientRect(); const i = Math.max(0, Math.min(11, Math.round((e.clientX - b.left) / b.width * 11))); const px = i / 11 * b.width;
x.style.left = px + 'px'; tt.style.left = Math.max(50, Math.min(px, b.width - 50)) + 'px';
[pa, pb].forEach((p, k) => { pts[k].style.left = px + 'px'; pts[k].style.top = p[i][1] / 140 * b.height + 'px'; pts[k].classList.toggle('on', on[k]); });
$('b', tt).textContent = (on[0] ? A[i].toLocaleString('en-US') : B[i].toLocaleString('en-US')) + ' editors'; const dv = Math.round((A[i] / B[i] - 1) * 100); $('span', tt).textContent = MO[i] + (on[0] && on[1] ? ', ' + (dv < 0 ? '−' : '+') + Math.abs(dv) + '% vs last year' : '');
v.textContent = (on[0] ? A[i] : B[i]).toLocaleString('en-US'); });
c.on(ch, 'pointerleave', () => v.textContent = (on[0] ? A[11] : B[11]).toLocaleString('en-US'));
$$('.lnx-k', r).forEach(k => k.onclick = () => { const s = +k.dataset.s; if (on[s] && !on[1 - s]) return; on[s] = !on[s]; k.classList.toggle('on', on[s]); $('.l' + s, svg).classList.toggle('off', !on[s]); if (s === 0) $('.ar', svg).style.opacity = on[0] ? 1 : 0;
// the big number rolls to the latest value of the line that is showing
const from = +v.textContent.replace(/,/g, ''), to = on[0] ? A[11] : B[11]; if (from !== to) tween(from, to, 600, n => v.textContent = Math.round(n).toLocaleString('en-US')); });
});
Questions
What people ask about it.
- How do I show "+69% vs last year" in the readout?
- Pass
compareLabel, like "vs last year". The readout then adds the percent change of the first series against the second at the hovered point, as long as both are visible. - Can I plot more than two series?
- Yes.
seriestakes any number; the first is the solid main line and the rest are dashed. The percent comparison only uses the first two. - How do I keep the y-axis from starting near the lowest value?
- Pass
rangeas [bottom, top]. Without it, the chart uses the data's range with a little room above and below. - Can I control which series are visible?
- Yes. Pass
visiblewith the keys to show and handleonVisibleChange, or usedefaultVisibleand let the legend manage it.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.