nanochart.js
v0.2.1
Published
Tiny zero-dependency canvas charting library with a plugin core and Telegram-style themes
Downloads
660
Maintainers
Readme
nanochart
Tiny canvas charting library with a plugin core and Telegram-style day/night themes.
See it running → — every series type, both themes, and an exchange dashboard built out of them.
- 16.1 kB gzip for a line chart with axes and a tooltip, 13.9 kB through the lean entries; 19.7 kB for all six series types plus every plugin. Unused plugins tree-shake away, and the lean entries leave out the series types the page does not register
- Zero runtime dependencies, single
<canvas>, no DOM overlays - Everything animates: y-axis rescaling, series toggling, zooming and theme switching
- Plugin core: axes, legend, tooltip and scrubber are plugins, and so is anything you add
- Written in strict TypeScript, shipped as ESM with type declarations
Install
npm install nanochart.jsThe npm package is
nanochart.js— the barenanochartname was taken by an unrelated placeholder. That is the specifier to import from; the exports themselves are unchanged.
Or drop the global build into a page:
<script src="nanochart.global.js"></script>Lean imports
The package entry registers all six series types, so a chart draws with nothing else set up — and so every import from it carries all six. Three entries leave the choice to the page:
import { Chart, registerSeries, telegramLight } from 'nanochart.js/core';
import { line } from 'nanochart.js/series';
import { tooltip, xAxis, yAxis } from 'nanochart.js/plugins';
registerSeries(line);/core is the chart, the themes and the helpers; /series the renderers, none
of them registered; /plugins the plugins. Nothing in the three has a side
effect, so a bundler keeps only what the page names. The framework wrappers use
the package entry.
Quick start
import { Chart, legend, rangeSelector, telegramLight, tooltip, xAxis, yAxis } from 'nanochart.js';
const chart = new Chart('#followers', {
theme: telegramLight,
height: 320,
x: { type: 'time' },
range: [0.6, 1],
series: [
{ id: 'joined', type: 'line', name: 'Joined', color: '#4bd964', data: joined },
{ id: 'left', type: 'line', name: 'Left', color: '#fe3c30', data: left },
],
plugins: [yAxis(), xAxis(), tooltip(), rangeSelector(), legend()],
});data accepts [5, 7, 3], [[timestamp, value], ...] or [{ x, y }, ...].
null, undefined and any non-finite number mark a gap: the line breaks there,
the fill splits, and the point drops out of the tooltip and the axis domain. A
bare null carries no x of its own, so it takes one from the samples it sits
between — on a regular grid, the slot that is missing. Hovering a gap reports
nothing — index: -1, no crosshair, no card — until the pointer reaches a sample
with a value, so a lone series with an outage has a dead zone the width of it;
a second series with a value there is hovered instead.
Series types
Axes
x: { type: 'time' } // timestamps
x: { type: 'category', categories: ['Mon', ...] } // one slot per sample
y: { type: 'log' } // orders of magnitude
y: { type: 'linear', min: 0, max: 100 }
x: { min: 0, max: 10, ticks: 4 } // bounds and a tick count on x toomin, max, zero and ticks apply to x as well: a pinned bound is where the
axis ends, headroom and all, and range is a fraction of the pinned extent;
ticks on x replaces the count xAxis({ spacing }) derives from the width.
new Chart('#chart', { locale: 'de-DE', timeZone: 'UTC', ... });Tick and tooltip formatting goes through Intl, so month names, weekday names,
number grouping and decimal separators follow locale, and day and month ticks
anchor to midnight in timeZone rather than on the machine drawing the chart.
Both default to the host. chart.formats exposes the same formatters if you
need them in a custom format callback, and createFormats(locale, timeZone)
builds a set before the chart exists — a tooltip title is passed in the
options, so it has no chart to read yet, and the standalone formatDate,
formatTime and formatMonth helpers always use the host zone.
A log axis ticks whole decades, adds 2s and 5s when there is room, takes each
label's precision from its own magnitude, and never has zero forced into it — not
even by a bar or area series. A category axis leaves half a slot at each end so
edge bars are whole.
| Type | Options | Notes |
| --- | --- | --- |
| line | lineWidth, curve, dash | curve is linear, smooth or step |
| area | fillOpacity, curve | Stack with stack: 'id', add normalize: true for 100% stacks |
| bar | barWidth, stack | Bars sharing a stack are stacked, the rest are grouped |
| candlestick | barWidth, upColor, downColor | Data is { x, open, high, low, close } |
| scatter | radius, fillOpacity | Dots, for correlations and distributions |
| pie | innerRadius | One series per slice, so the legend toggles slices |
Every series takes color, colorDark (used while a dark theme is active), axis
('y' or 'y2') and visible. Colors accept hex, rgb(), hsl() and the basic
CSS keywords everywhere; anything more exotic is resolved by the browser.
Negative values in a stack grow downwards from zero, so a pair of series makes a diverging bar chart:
series: [
{ id: 'profit', type: 'bar', name: 'Profit', stack: 'pnl', data: profit },
{ id: 'loss', type: 'bar', name: 'Loss', stack: 'pnl', data: loss }, // negative values
];Plugins
yAxis({ prefix: '$' }); // 67.5K -> $67.5K, -500 -> -$500
yAxis({ axis: 'y2', tinted: true }); // right axis, tinted with its series color
yAxis({ labelPosition: 'inside', color: '#fff' }); // labels on top of filled areas
yAxis({ placement: 'outside' }); // gutter beside the plot, sized to fit
yAxis({ backdrop: false }); // no wash of background behind overlaid labels
xAxis({ height: 26, spacing: 78, suffix: '%' });
tooltip({ total: true, format: (value, series, index) => `$${value}` });
tooltip({ total: true, formatTotal: (total, index) => `${total} in all` }); // the total row takes `format` otherwise
legend({ position: 'top', align: 'center' });
legend({ orientation: 'vertical', filter: (s) => s.axis === 'y' });
rangeSelector({ height: 44, minSpan: 0.06 });
zoom(); // wheel to zoom, drag to pan
zoom({ modifier: 'ctrl', drag: false }); // ctrl+wheel only, no panning
a11y({ summary: 'Revenue by day' }); // keyboard nav + hidden data tableTick labels pick their own unit and precision from the axis step, so $67.5K and
$68K never collapse into the same label. Two y axes are automatically put on the
same grid lines.
By default the y axis draws over the plot, Telegram style, with a wash of the
background behind each label so a muted label still reads on top of the first bar
or a filled area; backdrop: false leaves the labels bare, and so does a label
color of your own. placement: 'outside' reserves a gutter instead, measured
against the widest label the current domain produces, which is what wide labels
and conventional layouts want.
zoom anchors on the value under the cursor, so the point you are pointing at
stays put. It captures the pointer only once a drag actually moves, so a click
still hovers. chart.minSpan is the floor for how far in anything can zoom —
the scrubber and zoom can raise it, never lower it.
Plugins are drawn in list order and reserve screen space in reverse order, so the last plugin in the array sits closest to the canvas edge. A plugin is a plain object:
const watermark = {
name: 'watermark',
measure(chart, box) { box.h -= 20; }, // reserve space
drawUnder(ctx) { /* painted below the series */ },
drawOver(ctx) { /* painted above the series */ },
pointer(chart, event) { return false; }, // return true to capture the event
animating(chart, now) { return false; }, // keeps the render loop alive
};examples/annotations.js is a plugin that does something: horizontal thresholds and
vertical bands, placed by the scales of the frame being drawn and painted in the theme's
colours at that instant, so they follow an animating domain and cross-fade with a theme
switch — which is the reason to draw an annotation on the canvas rather than position a
div over it.
Custom series types work the same way:
import { registerSeries } from 'nanochart.js';
registerSeries({
type: 'dots',
draw(ctx, series) {
const y = ctx.scaleFor(series.axis);
const { x, y: values, length } = series.data;
for (let i = 0; i < length; i++) {
if (!Number.isFinite(values[i])) continue; // a gap
ctx.r.circle(ctx.x.map(x[i]), y.map(values[i]), 3, ctx.colorOf(series));
}
},
});readableOn(fill) is black or white, whichever reads on a fill; the pie labels
its slices with it.
Samples are stored columnar — series.data holds parallel Float64Arrays
(x, y, and open/high/low/close for OHLC input) rather than one
object per point. pointAt(series.data, i) materializes a single { x, y }
when an object is more convenient than the columns.
Accessibility
A canvas is opaque to assistive technology: role="img" and a label say that a
picture exists, not what is in it. The a11y plugin fixes both halves of that.
plugins: [tooltip(), a11y({ summary: 'Revenue by day' })]It renders the data as a visually hidden <table> beside the canvas — real
numbers, one column per series, formatted through the chart's locale — and
points the canvas at it with aria-describedby. It also makes the chart a focus
stop, so ← and → walk the points with the tooltip
following, Home and End jump to the ends, and
Esc clears. Long series are capped at maxRows with the total noted
in the caption.
The focus itself is marked by the browser, not painted on the canvas, so it matches the rest of the page and answers to CSS:
.chart canvas:focus-visible { outline: 2px solid #3390ec; outline-offset: 2px; }Framework wrappers
Thin bindings over the same core, in their own entry points so the plain
library is untouched. react and vue are optional peers.
import { NanoChart } from 'nanochart.js/react';
<NanoChart series={series} theme={theme} onChart={setChart} /><script setup>
import { NanoChart } from 'nanochart.js/vue';
</script>
<template><NanoChart :options="options" @ready="onReady" /></template><script>
import { nanochart } from 'nanochart.js/svelte';
</script>
<div use:nanochart={options} />All three share ChartController, which works out the narrowest update for
whatever changed: a new theme cross-fades, a changed series is patched rather
than replacing the list, and identical options do nothing at all. A wrapper is
handed a whole options object on every render, so treating that as "replace
everything" would restart every animation on an unrelated prop change.
Options the chart reads once — x, y, y2, plugins, padding,
animation, locale, timeZone, minSpan, ariaLabel — rebuild the chart
when they change, and onChart (React), ready (Vue) and onChart among the
action's options (Svelte) report the new instance. Two rules keep the diff
cheap. data is compared by identity and then by length: replace the array or
push to it, but a value edited in place is not seen. A plugin is known by its
name: a list rebuilt from the same plugins is the same list, and a plugin's own
options are read when the chart is built.
Themes
telegramLight and telegramDark ship with the library; createTheme(base, overrides)
derives new ones. chart.setTheme(theme) cross-fades every color, including the palette
and any extra color keys your own theme adds, instead of snapping.
chart.setTheme(dark ? telegramDark : telegramLight);A theme is a flat map of colors plus a palette array and a dark flag, so a brand theme
is a dozen lines. positive and negative drive candles and any gain/loss coloring.
API
chart.setTheme(theme, animate?) // animated theme cross-fade
chart.setSeries(series, animate?) // replace the whole dataset
chart.updateSeries(id, patch) // patch one series, in place
chart.toggle(id, visible?) // show or hide with animation
chart.setRange(from, to, animate?) // visible window, 0..1 of the full extent
chart.range() // current window
chart.setHeight(height?) // pin the height, or omit to follow the container
chart.resize() // usually handled by ResizeObserver
chart.render() // force a synchronous frame
chart.destroy()
chart.on('hover' | 'select' | 'rangechange' | 'toggle' | 'themechange', handler)hover and select report { index, reference, seriesId }: index counts into
the series named by reference — the one under the pointer, which on series with
their own x grids is not always the same one — and a pie slice, which has no
index, names its series as seriesId. select is a click: a press that a plugin
dragged — a pan, a scrubber handle — is not one, and neither is a touch the
browser took for scrolling.
Without height the chart takes the content height of its container, which
therefore needs a height of its own. A container sized by its content would only
be measuring the canvas back, so the chart is 240px tall there.
Performance notes
- One canvas per chart, one
requestAnimationFrameloop that stops when nothing animates. - Samples live in typed-array columns, so a million points cost a million numbers rather than a million objects.
- Every series type decimates. Lines and areas keep the first, last, lowest and highest sample of each pixel column, so a one-sample spike survives instead of being skipped; bars, candles and dots collapse per column the same way.
- Bars are batched into a single path per series.
- Text metrics are cached, colors are parsed once per string, and the minimum x step of a series is computed once rather than per frame.
Examples
Both pages are deployed from main, so they are the current build:
the basics and
the dashboard. To run them
against your own changes:
npm install
npm start # builds and serves on http://localhost:4173examples/index.html— the basics: lines, bars, stacked areas, dual axes and a donut.examples/crypto.html— an exchange dashboard: a tape streaming a trade a second throughupdateSeries, price and volume sharing one window overrangechange, a headline price above the canvas driven byhover, candles, order book depth, diverging P&L bars, 100% stacked areas, a logarithmic latency axis, a categorical axis, an axis in its own gutter, a series with a collection gap, ctrl+wheel zoom, keyboard navigation, a custom heatmap series and a custom annotation plugin.
npm run check:examples loads both pages headlessly and fails if anything throws or a
chart never gets a canvas. CI runs it, and so does the Pages deploy — the examples are
the only end-to-end use of the public API.
License
MIT
