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.

Live demo
Every code example on this page is runnable. The boxes below each example show the chart updating in real time.

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>
Pre-1.0: pin the exact version
MiniChart is on 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

FileFormatResolved by
minichart.jsscript / CommonJS, unminifiedrequire()
dist/minichart.mjsES moduleimport, bundlers
dist/minichart.min.jsscript / CommonJS, minified<script>, CDN
dist/minichart.min.mjsES module, minifiedmanual imports
minichart.d.tsTypeScript declarationspicked 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.

Verified, not assumed
The declarations are checked on every 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: '%',
});
▶ Result

Constructor #

new MiniChart(canvas, opts?)

paramtypedescription
canvasHTMLCanvasElementTarget 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.
optsMiniChartOptionsConfiguration object. All fields are optional. See Options.
Throws
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:

Options #

All options can be set at construction and hot-swapped later via update().

optiontypedefaultdescription
type'line' | 'bar''line'Render mode. 'bar' draws discrete bars growing from the zero baseline.
seriesMiniChartSeries[][]The data series. See Series.
labelsnumber[][]X-axis timestamps in epoch seconds, ascending, parallel to each series' data. When omitted/empty, the X axis falls back to the sample index.
rangeSecnumberspan of labelsMinimum 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.
maxSamplesnumber~2× plot width, or the initial countUpper 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 }).
validatebooleanfalseDev-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.
yMinnumber | null0Fixed 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).
yMaxnumber | nullnullFixed Y max. null = auto from data (+10% headroom).
yUnitstring''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).
yTicksnumber4Number of Y-axis intervals; draws yTicks + 1 horizontal grid lines and Y labels (default 4 → 5 lines).
tensionnumber0.5Curve smoothing, 0 = straight segments, 1 = full Fritsch–Carlson monotone cubic. Monotonicity is always preserved — never overshoots data bounds.
fillboolean | 'flat'trueArea fill under each line series. true (default) = vertical gradient; 'flat' = solid alpha, far cheaper to rasterize (preferable for streaming/repaint-heavy charts); false = none.
paddingMiniChartPadding{ 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.
xFormatXFormat'time'X-axis formatter. See Formatters.
yFormatYFormatnullY-axis formatter override. See Formatters.
legendbooleantrueShow a clickable HTML legend above the canvas. Click toggles series visibility.
themePartial<MiniChartTheme>dark defaultsTheme tokens. See Theme.
onMiniChartHandlersnullEvent handlers. See Event handlers.
tooltipMiniChartTooltipOptions{ 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
}

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
}
▶ Try it: switch theme

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:

Built-in Y notation
Used when 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'
▶ Try it: switch Y formatter

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:

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' } });
Both default to dashboard-friendly values (zeros hidden, tooltip off the plot). Restore the original compact behaviour with tooltip: { hideZero: false, position: 'auto' }.
▶ Try it: hover the line — one busy container among idle ones

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);
Note
Changing labels or series resets any active zoom — the viewport would otherwise point at timestamps that no longer exist.
Note
theme is merged, not replaced: update({ theme: { grid: '#333' } }) keeps every other token. Passing a partial theme never blanks the rest.
▶ Try it: live update() every 800 ms
streaming…

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);
When push, not update
A metric scraped every second, or a multi-chart dashboard updating each frame. 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).
Bounded & scrolling
A sliding window of at most 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.
▶ Try it: live push() every frame (a real-time stream)
streaming…

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)
▶ Try it: toggle visibility programmatically

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
▶ Try it: zoom presets

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

eventpayloadfires 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
▶ Try it: hover and click to see events
events will appear here…

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.

Give the canvas its own wrapper
The chart appends an overlay canvas and a legend as siblings of the canvas, inside its parent element. Point your framework at the inner <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>
  );
}
Watch the identity of your props
The update effect compares 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:


MiniChart v0.1 · MIT license · generated from minichart.d.ts · interactive demo →