Skip to content
Star on GitHub
Charts and tablesLine chart
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Line 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.

$npx designme add line-chart
Active editors0
JanMarMayJulSepNov
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add line-chart

React

$npx designme add line-chart --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything LineChart takes.

PropTypeWhat it does
labelrequiredstringThe name over the big number, like "Active editors"
seriesrequiredLineChartSeries[]The first series is the main one: a solid line with a soft fill. The others are dashed, to compare against
pointsrequiredstring[]One name per point, shown in the hover readout, like the months
axisstring[]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
unitstringWhat the values count, like "editors" in "1,620 editors"
compareLabelstringEnds the readout's comparison of the first two series, like "vs last year" in "+69% vs last year"
format(n: number) => stringWrites a value. Defaults to whole numbers like 1,620
visiblestring[]Keys of the series shown. Leave it out to let the legend toggle them. One always stays on
defaultVisiblestring[]
onVisibleChange(keys: string[]) => void
classNamestring
05

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')); });
});
06

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. series takes 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 range as [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 visible with the keys to show and handle onVisibleChange, or use defaultVisible and let the legend manage it.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.