Overview #
MiniChart is one of the smallest and fastest dependency-free time-series charting libraries — under 15 KB gzipped, zero dependencies, on pure Canvas 2D. It keeps only the source index of each retained sample (4 bytes, not a per-point object), so large datasets stay light on memory. Built for monitoring dashboards: dense series, frequent live updates, and honest rendering of missing data (gaps, not connectors; a reading the gaps leave alone is drawn as a dot rather than dropped).
Two render paths cover every workload. update()
hot-swaps a dataset through a full O(n) recalc — a million points in ~12 ms, inside
one frame. push() appends one sample at
O(1) cost that is flat in n — ~0.1 ms whether the chart
holds 10k or 1M points — so a dashboard of many streaming charts holds 60 fps where
update() drops frames. Repaint of the cached path is ~0.3 ms
at a million points.
Install #
Every entry point below exposes the same class. Pick by how your app loads code.
From a CDN
Defines the MiniChart global. Nothing to build.
<script src="https://unpkg.com/minichart@0.1.0/dist/minichart.min.js"></script>
<script>
const chart = new MiniChart(canvas, { /* … */ });
</script>
0.x — the public API is still settling, and per semver a
minor bump may include breaking changes. For production, pin the exact version
(@0.1.0); use @0.1 or @latest
only for experimentation. (A bare https://unpkg.com/minichart serves the newest
release, which may break you on the next minor.) The same paths work on jsDelivr:
https://cdn.jsdelivr.net/npm/minichart@0.1.0/dist/minichart.min.js.
The API will graduate to the stable 1.x line once it holds up under real use.
Self-hosted
Copy dist/minichart.min.js next to your other assets and load it with a
plain <script>. It is the same file the CDN serves — one request, no
dependencies, and a .map alongside it so DevTools still shows the original
source when you debug.
From a package manager
The package is plain npm metadata, so every manager installs it the same way — there is nothing registry-specific in it.
npm install minichart
pnpm add minichart
yarn add minichart
bun add minichart
deno add npm:minichart
// ES modules — Vite, webpack, Rollup, Next, Nuxt
import MiniChart from 'minichart';
// CommonJS — the module *is* the class, not a namespace
const MiniChart = require('minichart');
What ships in the package
| File | Format | Resolved by |
|---|---|---|
minichart.js | script / CommonJS, unminified | require() |
dist/minichart.mjs | ES module | import, bundlers |
dist/minichart.min.js | script / CommonJS, minified | <script>, CDN |
dist/minichart.min.mjs | ES module, minified | manual imports |
minichart.d.ts | TypeScript declarations | picked up automatically |
Bundlers pick the ES module and minify it themselves, so importing from
'minichart' already gives you a minified build in production — there is no
separate "min" import to remember.
TypeScript
Types ship inside the package — no @types/minichart to install and
nothing to configure. package.json points at
minichart.d.ts, so editors and tsc find it
on their own.
import MiniChart, { MiniChartOptions, MiniChartSeries } from 'minichart';
// MiniChart is both a value and a type, so it fits a typed ref directly:
const chartRef = useRef<MiniChart | null>(null);
Exported types: MiniChartOptions,
MiniChartSeries, MiniChartTheme,
MiniChartPadding, MiniChartHandlers,
MiniChartRange, MiniChartXRange,
ChartType, XFormat, YFormat,
ReadyPayload, PointEventPayload, SeriesTogglePayload.
npm test against two fixtures:
one that must compile under strict, and one where every line must be
rejected. The second half is the point — declarations that quietly degraded to
any would otherwise still look green. Option typos are caught as well:
yTikcs yields "Did you mean to write 'yTicks'?".
Quick start #
The smallest useful chart — a single line with gradient fill and a time axis:
const chart = new MiniChart(canvas, {
series: [{ label: 'CPU', color: '#58a6ff', data: [10, 35, 22, 60, 45, 80] }],
labels: [1640000000, 1640000060, 1640000120, 1640000180, 1640000240, 1640000300],
rangeSec: 300, // total X span in seconds
yMin: 0, yMax: 100, yUnit: '%',
});
Constructor #
new MiniChart(canvas, opts?)
| param | type | description |
|---|---|---|
| canvas | HTMLCanvasElement | Target canvas. May report a zero CSS size at construction (an inline <script> before layout, or a mount inside a not-yet-shown container) — MiniChart polls via requestAnimationFrame and renders on the first frame where the canvas has a real size. Only a canvas that is permanently zero-sized never paints. A ResizeObserver and a devicePixelRatio listener are installed automatically, so the canvas may be fluid. |
| opts | MiniChartOptions | Configuration object. All fields are optional. See Options. |
Error('MiniChart: 2D canvas context unavailable') if canvas is null/undefined, is not a real <canvas> (no getContext), or canvas.getContext('2d') returns null — fail-fast so the caller gets a clear message instead of a cryptic downstream TypeError.
At construction MiniChart will:
- size the backing store to
cssSize × devicePixelRatio; - insert a transparent overlay canvas (for the cheap hover layer) — only if the canvas has a parent element;
- insert the legend element (if
legend: trueand the canvas has a parent element); - wire Pointer Events, keyboard handler, ResizeObserver, DPR tracker;
- fire the
readyevent once, at the end of construction (before the first frame if the canvas has not been laid out yet —width/heightare thenundefineduntil layout).
Options #
All options can be set at construction and hot-swapped later via update().
| option | type | default | description |
|---|---|---|---|
| type | 'line' | 'bar' | 'line' | Render mode. 'bar' draws discrete bars growing from the zero baseline. |
| series | MiniChartSeries[] | [] | The data series. See Series. |
| labels | number[] | [] | X-axis timestamps in epoch seconds, ascending, parallel to each series' data. When omitted/empty, the X axis falls back to the sample index. |
| rangeSec | number | span of labels | Minimum X-axis span in seconds — a floor, not a ceiling: the axis still stretches to hold every retained label. Lets a 40-minute data window sit honestly on the left of a 12-hour dashboard tile. |
| maxSamples | number | ~2× plot width, or the initial count | Upper bound on samples held in a streaming chart (see push()). Past the cap up to 10% of it is evicted per pass, but only rows the window has already scrolled past — trimming on-screen rows would leave the min/max buckets describing samples the data no longer holds. Changeable on a live chart with update({ maxSamples }). |
| validate | boolean | false | Dev-mode contract check. Construction, update() and push() then throw on labels that are non-finite or descending, or on a series whose data is not the same length as labels. Off, these fail silently as a wrong-shaped chart. O(n) per call — leave it off in production. |
| yMin | number | null | 0 | Fixed Y min. null = auto from visible data (+10% headroom). Auto-min is relaxed past a pinned value when the data does not fit (all-negative series). |
| yMax | number | null | null | Fixed Y max. null = auto from data (+10% headroom). |
| yUnit | string | '' | Mode selector for the built-in Y formatter (overridden by yFormat). Only 'MB' and 'MB/s' are recognised — they switch the axis to GB/k scaling; any other value (incl. '%') is a visual no-op (the unit string is not appended — use yFormat or a CSS suffix for that). |
| yTicks | number | 4 | Number of Y-axis intervals; draws yTicks + 1 horizontal grid lines and Y labels (default 4 → 5 lines). |
| tension | number | 0.5 | Curve smoothing, 0 = straight segments, 1 = full Fritsch–Carlson monotone cubic. Monotonicity is always preserved — never overshoots data bounds. |
| fill | boolean | 'flat' | true | Area fill under each line series. true (default) = vertical gradient; 'flat' = solid alpha, far cheaper to rasterize (preferable for streaming/repaint-heavy charts); false = none. |
| padding | MiniChartPadding | { top: 12, right: 10, bottom: 22, left: 38 } | Plot-area insets in CSS pixels. left auto-sizes to max(28, widest Y label + 10) unless set explicitly. |
| xFormat | XFormat | 'time' | X-axis formatter. See Formatters. |
| yFormat | YFormat | null | Y-axis formatter override. See Formatters. |
| legend | boolean | true | Show a clickable HTML legend above the canvas. Click toggles series visibility. |
| theme | Partial<MiniChartTheme> | dark defaults | Theme tokens. See Theme. |
| on | MiniChartHandlers | null | Event handlers. See Event handlers. |
| tooltip | MiniChartTooltipOptions | { hideZero: true, position: 'outside' } | Tooltip behaviour. See Tooltip. |
Series #
interface MiniChartSeries {
label: string; // legend + tooltip
color: string; // #rgb | #rrggbb | rgb() | named
data: Array<number | null>; // parallel to chart.labels
notes?: Array<string | null>; // optional per-point tooltip note
}
- color accepts any CSS color. Invalid values degrade to neutral grey instead of crashing.
- data entries may be
null/NaN/±Infinity— these create a gap in the line (not a misleading connector) and are excluded from Y auto-scaling, so one dead sample can't poison the axis. - notes render under the value in the tooltip. Useful for derived context:
'"/" 79% → "2.3 GB of 3 GB, 482 MB free"'.
Theme #
Override any subset to adapt to a light dashboard or a custom palette.
interface MiniChartTheme {
grid: string; // horizontal grid line color
axisLabel: string; // Y/X axis tick label color
crosshair: string; // hover crosshair color
dotStroke: string; // hover dot outline color
tooltipBg: string; // tooltip background
tooltipText: string; // tooltip text color
tooltipBorder: string; // tooltip border color
legendText: string; // legend text color
}
Formatters #
xFormat
type XFormat =
| 'time' // adaptive by rangeSec (default 3600): ≤1800s→HH:MM:SS, ≤86400s(1d)→HH:MM, ≤2419200s(4wk)→DD.MM HH:MM, else→DD.MM.YY. Locale is fixed to 'ru'.
| 'number' // raw numeric label, no time parsing
| ((value: number, index: number, chart: MiniChart) => string); // custom
yFormat
type YFormat = ((value: number, chart: MiniChart) => string) | null;
When provided, the built-in compact notation (with yUnit handling) is bypassed. Detach your domain language from the chart core:
yFormat is null (the default): a missing value renders as — (em-dash); yUnit: 'MB' switches the whole axis to GB above 1024; yUnit: 'MB/s' collapses values ≥1000 to k; otherwise values ≥1000 show as 1.5k, ≥100 round, and the rest use one decimal. yUnit is a mode keyword for the first two cases only — it is never appended as a string suffix.
yFormat: (v) => v < 1024 ? Math.round(v) + ' B'
: v < 1048576 ? (v/1024).toFixed(1) + ' KB'
: (v/1048576).toFixed(1) + ' MB'
Event handlers opts.on #
interface MiniChartHandlers {
ready?: (p: { width: number | undefined; height: number | undefined }, chart: MiniChart) => void;
hover?: (p: { index: number; event?: Event }, chart: MiniChart) => void;
click?: (p: { index: number; event?: Event }, chart: MiniChart) => void;
seriesToggle?: (p: { index: number; visible: boolean }, chart: MiniChart) => void;
}
Errors thrown inside a handler are caught and reported via console.error — a bad callback never breaks drawing. See Event reference for payloads.
Tooltip opts.tooltip #
The hover tooltip is plain HTML attached to document.body, so it is never clipped by the chart's container. Override any subset of:
interface MiniChartTooltipOptions {
hideZero?: boolean; // hide a row whose value === 0 (default true; incl. -0)
position?: 'outside' | 'auto'; // placement (default 'outside')
}
hideZero boolean · default true — drops a series' row from the tooltip when its value at the hovered timestamp is exactly 0. On a dashboard of many containers most read 0 at any instant, so this keeps the tooltip on the one active series instead of a wall of zeros. Precisely:
- Hidden — any value
=== 0(this includes-0). - Kept — every non-zero value;
0.001is shown, only an exact0is dropped. - Always hidden, regardless of
hideZero—null/NaN/±Infinity; these are gaps the line does not draw, so they never carried a reading to hide. - Disable it where
0is itself a meaningful reading (a temperature, a delta, a counter reset):tooltip: { hideZero: false }.
Hovering 8 containers where only transmission is busy — the same instant, two settings:
// hideZero: true (default) hideZero: false
//
// 17:22:29 17:22:29
// transmission 137 filebrowser 0
// home-ui 0
// interview-backend 0
// transmission 137
// vk-miniapp-caddy-1 0
// …
position 'outside' | 'auto' · default 'outside' — 'outside' places the tooltip off the plot rectangle (beside the plot, or above/below the plot band) so it never covers the data being inspected (Grafana-style), falling back to the 'auto' anchor when no side of the plot has room; 'auto' anchors it to the hovered sample — above it when it fits, else below.
new MiniChart(canvas, {
series: containers, // many series, most idle
tooltip: { hideZero: true, position: 'outside' }, // the defaults
});
// hot-swap either knob later without touching the rest:
chart.update({ tooltip: { position: 'auto' } });
// restore the original compact tooltip:
chart.update({ tooltip: { hideZero: false, position: 'auto' } });
tooltip: { hideZero: false, position: 'auto' }.Methods
chart.update(opts) #
Hot-swap any subset of options without recreating the instance. Resets hover state, recomputes geometry for options that affect it (series, labels, yMin/Max, tension, padding, type, …), and repaints. Safe to call from a 60 fps live-data loop.
// Rolling live-data window
setInterval(() => {
data.push(data.shift() + (Math.random() - 0.5) * 5);
chart.update({ series: [{ label: 'live', color: '#3fb950', data: data.slice() }] });
}, 1000);
labels or series resets any active zoom — the viewport would otherwise point at timestamps that no longer exist.
theme is merged, not replaced: update({ theme: { grid: '#333' } }) keeps every other token. Passing a partial theme never blanks the rest.
chart.push(value, label?, seriesIdx?) #
The streaming fast path: append one sample to a series and re-render incrementally. Where update() re-runs a full O(n) recalc on every call, push() keeps, per series, a ring of per-pixel-column min/max buckets over a sliding window — one bucket updates per sample (O(1)) and the repaint rebuilds only the ~plotW-vertex paths (O(plotW·series)). The cost is flat in n: 10k, 100k and 1M points all measure ~0.1 ms per push, so a dashboard of many streaming charts holds 60 fps where update() drops frames.
type: 'bar' is the exception: a bar is drawn per sample, and a per-column bucket holds two extremes rather than the samples that fell in the column, so there is nothing to stream. push() appends and takes the full O(n) recalculation for bar charts.
// Live feed: one push per sample — no full rebuild, flat in n
setInterval(() => {
chart.push(cpu(), Date.now() / 1000, 0); // value, epoch-sec label, series index
chart.push(mem(), Date.now() / 1000, 1); // second series, same timestamp
}, 1000);
push() is flat in n; update() is O(n) per call. Pick one path per chart — after push() the bucket rings own the geometry, and the next update() drops them (its full recalc rebuilds the batch geometry and nulls the streaming state; the next push re-seeds it).
opts.maxSamples samples (default ~2× the plot width, or the initial sample count — whichever is larger); past the cap the rows that have scrolled off the window are evicted, so the chart streams indefinitely without growing memory. Pass label as epoch seconds for a scrolling time axis (set rangeSec to size the window to your cadence); omit it for index-based streaming. Multi-series, area fill and hover all work; a null/NaN value is a gap.
chart.setVisibleSeries(indices) #
Programmatic equivalent of clicking legend items. Recomputes the Y range from the visible set (a hidden noisy series no longer stretches the axis) and updates the legend.
chart.setVisibleSeries([0, 2]); // show only series 0 and 2
chart.setVisibleSeries([]); // empty → show all (guard, never empty)
chart.setXRange(min, max) · zoom #
Narrow the visible X window. Both bounds are clamped into the data domain and reordered if swapped. null / NaN / ±Infinity reset the zoom. This is the foundation for interactive zoom/pan and cross-chart cursor sync.
const { domain } = chart.getXRange();
chart.setXRange(domain.max - 3600, domain.max); // zoom to last hour
chart.setXRange(null, null); // reset
chart.getXRange() #
Read-only accessor for the current viewport and the full data domain. Drives minimaps, "reset zoom" affordances, and cross-chart sync.
const { view, domain } = chart.getXRange();
// view: { min, max } — currently visible window
// domain: { min, max } — full data span (zoom reset target)
chart.repaint() #
Repaint from the cached geometry without recomputing it — the cheap path for anything that changes how the chart looks but not where its points are: a container that was hidden while the chart was built, a canvas cleared by something else on the page, or a manual redraw after drawing over it. It is flat in the number of samples (~0.3 ms at a million points), because it strokes the already-built Path2D objects and never re-projects. Data and size changes must go through update() or the resize path instead.
chart.repaint(); // re-stroke cached paths; no geometry rebuilt
chart.destroy() #
Detach all listeners (Pointer/mouse, keyboard, ResizeObserver, matchMedia), cancel pending requestAnimationFrames, remove the overlay canvas / tooltip / legend / a11y live region from the DOM, and null out instance fields. Idempotent — safe to call twice. Call on unmount to prevent leaks.
// React / Vue / Svelte unmount hook
onUnmount(() => chart.destroy());
Event reference #
Fired via opts.on. Each handler receives (payload, chart).
| event | payload | fires when |
|---|---|---|
| ready | { width, height } | once, at the end of construction; after the first frame only when the canvas already has a non-zero size — otherwise before, with width/height undefined until layout |
| hover | { index, event? } | hovered index changes; index === -1 on pointer leave; also fires on keyboard nav |
| click | { index, event? } | canvas click, or Enter/Space during keyboard nav |
| seriesToggle | { index, visible } | after a legend click; visible reflects the new state |
React & Vue #
MiniChart owns a canvas and draws imperatively, so the integration in any framework is
the same three steps: create it once after mount, feed it new data through
update(), and call destroy() on unmount.
Never re-create the chart just because data changed — update() reuses
the instance and is built for live loops.
<canvas> and
leave the wrapper otherwise empty — if the framework also renders children there, its DOM diffing
and the chart's nodes will fight over the same parent.
React
import { useEffect, useRef } from 'react';
import MiniChart from 'minichart';
export function Chart({ series, labels, rangeSec, height = 200 }) {
const canvasRef = useRef(null);
const chartRef = useRef(null);
// Create once. The cleanup runs on unmount and, in StrictMode, between
// React's double-invoked mounts — destroy() is idempotent, so that is safe.
useEffect(() => {
chartRef.current = new MiniChart(canvasRef.current, {
series, labels, rangeSec: rangeSec,
});
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, []);
// Push data in. Cheap enough to run on every render that changes it.
useEffect(() => {
chartRef.current?.update({ series, labels, rangeSec: rangeSec });
}, [series, labels, rangeSec]);
return (
<div style={{ width: '100%', height }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
</div>
);
}
series by reference. Building the array inline
in the parent — <Chart series={[{ … }]} /> — makes a new array every
render, so the effect fires every time. Keep it in state, or memoise it with
useMemo.
Vue 3
<script setup>
import { ref, shallowRef, toRaw, onMounted, onBeforeUnmount, watch } from 'vue';
import MiniChart from 'minichart';
const props = defineProps({
series: Array, labels: Array, rangeSec: Number,
});
const canvasEl = ref(null);
// shallowRef, not ref: the chart holds a canvas and typed arrays, and making
// all of that deeply reactive would be pure overhead — Vue would proxy every
// coordinate object it touches.
const chart = shallowRef(null);
onMounted(() => {
chart.value = new MiniChart(canvasEl.value, {
series: props.series,
labels: props.labels,
rangeSec: props.rangeSec,
});
});
onBeforeUnmount(() => {
chart.value?.destroy();
chart.value = null;
});
watch(() => [props.series, props.labels, props.rangeSec], () => {
// toRaw() strips Vue's reactive proxy: the chart iterates these arrays on
// every frame, and reading through a proxy costs far more than a copy.
chart.value?.update({
series: toRaw(props.series),
labels: toRaw(props.labels),
rangeSec: props.rangeSec,
});
}, { deep: false });
</script>
<template>
<div class="chart"><canvas ref="canvasEl"></canvas></div>
</template>
<style scoped>
.chart { width: 100%; height: 200px; }
.chart canvas { width: 100%; height: 100%; display: block; }
</style>
Server-side rendering
MiniChart touches document and window in its
constructor, so it cannot run during SSR. In Next.js and Nuxt the code above is already safe —
useEffect and onMounted only run in the browser.
If you import it at module scope in a server-rendered file, load it lazily instead:
const { default: MiniChart } = await import('minichart');
Sizing
The chart follows its canvas via ResizeObserver, so a wrapper sized in
CSS is enough — no manual resize handling, and no need to re-create anything when a flex or grid
layout reflows. Give the canvas a CSS width and height, as in both examples above; the intrinsic
width/height attributes are managed for you and
set for the display's pixel ratio.
Recipes #
Live dashboard tile
const data = new Array(60).fill(0);
const labels = Array.from({ length: 60 }, (_, i) => Math.floor(Date.now()/1000) - 60 + i);
const chart = new MiniChart(el, { labels, rangeSec: 60, yMin: 0, yMax: 100, yUnit: '%',
series: [{ label: 'CPU', color: '#58a6ff', data }] });
setInterval(() => {
data.push(getCpu()); data.shift();
chart.update({ series: [{ label: 'CPU', color: '#58a6ff', data: data.slice() }] });
}, 1000);
Custom HTML tooltip via on.hover
new MiniChart(c, {
legend: false, // we render our own UI
on: {
hover: ({ index }, chart) => {
if (index < 0) return hideTooltip();
const t = chart.opts.labels[index];
const v = chart.opts.series[0].data[index];
showTooltip(t, v, chart.xPixelForIndex(index));
},
},
});
Threshold line on the overlay canvas
function drawThreshold(chart, value, color) {
const ctx = chart.getOverlayContext(); // transparent overlay above the series
const r = chart.yRange, { padding } = chart.opts;
const plotH = chart.height - padding.top - padding.bottom;
const y = padding.top + plotH - ((value - r.min) / (r.max - r.min || 1)) * plotH;
ctx.strokeStyle = color; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(chart.width - padding.right, y);
ctx.stroke(); ctx.setLineDash([]);
}
Accessibility #
The canvas automatically gets role="img", tabindex="0", and a descriptive aria-label. A visually hidden aria-live="polite" region is created on first keyboard navigation and announces the values under the cursor as you move between points — the aria-label alone is only read on focus, so keyboard navigation would otherwise be silent. Keyboard users can:
- Tab to focus the chart,
- ← / → (or ↑ / ↓) to move between points,
- Enter / Space to fire a
clickevent, - Esc to clear the hover.
MiniChart v0.1 · MIT license · generated from minichart.d.ts · interactive demo →