Data visualization made of particles. Line, area, bar, bubble, radar, pie and
donut charts rendered as living clouds of light on a <canvas>, driven by
plain JSON.
Zero runtime dependencies. One file — 21 kB minified + gzipped. Responsive, themeable, accessible and typed.
new ParticleChart('#chart', {
type: 'line',
data: [12, 19, 15, 27, 24, 33]
});Live demo → · or open index.html from a
clone, or npm start to serve it on http://localhost:4173.
v1.0.0 — the option names, the data shapes and the method signatures below are the stable surface. Anything that changes them from here gets a major.
CDN — nothing to configure, no build step, works over file:// too:
<script src="https://cdn.jsdelivr.net/npm/particle-charts@1/dist/particle-charts.min.js"></script>
<div id="chart" style="height: 320px"></div>
<script>
new ParticleCharts.ParticleChart('#chart', {
type: 'bar',
data: { labels: ['Q1', 'Q2', 'Q3', 'Q4'], values: [42, 58, 51, 73] }
});
</script>The script defines a single global, ParticleCharts. unpkg serves the same
file: https://unpkg.com/particle-charts@1/dist/particle-charts.min.js. Drop
the .min for the readable build. Pin an exact version (@1.0.0) in
production rather than a range.
npm — ES module source is shipped as-is, with TypeScript declarations, so
there is no @types package to install:
npm install particle-chartsimport { ParticleChart, donut } from 'particle-charts';
const chart = new ParticleChart(element, { type: 'donut', data });
donut(element, { Direct: 4820, Search: 3140 }); // shorthandCommonJS resolves to the UMD build, so require works too (Node 18+):
const { ParticleChart } = require('particle-charts');| File | Use |
|---|---|
dist/particle-charts.min.js |
CDN / <script> — 21 kB gzipped, defines window.ParticleCharts |
dist/particle-charts.js |
same, unminified, for debugging |
dist/particle-charts.cjs |
require() |
dist/particle-charts.esm.js |
<script type="module"> straight from a clone; re-exports src/ |
src/index.js |
import — what bundlers get |
index.d.ts |
TypeScript |
The container needs a height. The chart fills it and re-lays out whenever it changes.
Everything is normalized into { labels, series }, so all of these work:
data: [4, 8, 15, 16, 23, 42] // bare numbers
data: [{ label: 'Jan', value: 4 }, { label: 'Feb', value: 8 }]
data: { labels: ['Jan', 'Feb'], values: [4, 8] }
data: { Chrome: 62, Safari: 19, Firefox: 11 } // a plain record
data: { // multi-series
labels: ['Q1', 'Q2', 'Q3', 'Q4'],
series: [
{ name: 'Revenue', data: [42, 58, 51, 73], color: '#3987e5' },
{ name: 'Costs', data: [30, 33, 36, 41] }
]
}
data: { series: [{ name: 'Load', data: [{ x: 0, y: 12 }, { x: 5, y: 19 }] }] }
data: { series: [{ name: 'Teams', data: [{ x: 12, y: 34, r: 18 }] }] } // bubbleA point object is read by any of the usual keys — label, name, x, key,
category or date for the category, and value, y, count, total or
amount for the number. A series can be given as data, values or points,
and the series list as series or datasets.
null, undefined and unparseable values become gaps: a line breaks at them,
a bar is simply not drawn, and a radar polygon skips the spoke rather than
pinning it to the center.
When every x value is numeric, line and bubble charts switch to a continuous x axis automatically. Otherwise the labels are treated as categories.
The bubble chart reads a size from a third slot — r, size, z, radius or
weight on a point object, the third element of a tuple, or a parallel sizes
array. Every other chart type ignores it.
data: [{ x: 12, y: 34, r: 18 }, { x: 26, y: 52, r: 44 }] // point objects
data: [['Jan', 34, 18], ['Feb', 52, 44]] // tuples
data: { labels: ['Jan', 'Feb'], values: [34, 52], sizes: [18, 44] }Bubble series keep their own x positions, so two series can sit at genuinely different x rather than being forced onto a shared set of categories. The x axis becomes continuous with round ticks of its own — the marks fall between them, where their values put them.
Sizes map to radius by area: a value twice as large covers twice the ink. Scaling the radius directly instead — the usual bubble-chart mistake — would draw it four times as large. Without any size data the chart still works; every mark takes the mid radius and you get a plain scatter.
type |
Aliases | Notes |
|---|---|---|
line |
— | Monotone-cubic smoothing by default; never overshoots the data. |
area |
— | The same chart with the fill under the curve turned on. |
bar |
column |
Grouped by default; stacked: true and horizontal: true available. |
bubble |
scatter |
Scatter with a third value in the mark size. |
radar |
spider |
One spoke per label, one closed polygon per series. |
pie |
— | Reads the first series; each category gets its own color slot. |
donut |
— | pie with innerRadius: 0.62 and a running total in the hole. |
Nested groups (particle, axis, legend, tooltip, line, bar,
bubble, radar, pie) can be set either way where a flat alias exists —
{ particleBloom: 0.8 } and { particle: { bloom: 0.8 } } are the same thing,
and the nested form wins if you write both. Options listed below in dotted form
(bar.fade) have no alias and are set on the group.
| Option | Default | Description |
|---|---|---|
type |
'line' |
line, area, bar, bubble, radar, pie, donut. |
data |
— | See above. The one option that is not optional. |
theme |
'dark' |
dark or light. See Theming. |
background |
'transparent' |
Canvas fill painted behind the particles. |
padding |
auto | Number, [y, x], [top, right, bottom, left], or {top, right, bottom, left}. Left alone, it is measured from the axis labels actually drawn. |
responsive |
true |
Re-layout on container resize. |
maxDpr |
2 |
Cap on the HiDPI backing store. |
pauseWhenHidden |
true |
Stop the loop when the tab hides or the chart scrolls out of view. |
animate |
true |
Entrance flight. |
duration |
1100 |
Entrance duration, ms. |
stagger |
0.5 |
0–1, how much of the entrance is spread across particles. |
| Option | Default | Description |
|---|---|---|
particleColor |
palette | A color, an array of colors, or fn(index, series). |
particleSize |
0.8 |
Particle radius in CSS pixels. At or below 1.6 a particle is drawn as a pixel-snapped disc rather than a glow sprite — crisp, and about ten times cheaper per mark. |
particle.sizeJitter |
0 |
0–1 random size spread. 0 keeps every particle identical. |
particleDensity |
15 |
Multiplier on the auto-computed budget. |
particleCount |
50000 |
Hard ceiling, whatever the density. |
particleBloom |
0.8 |
Additive glow strength, 0–1. |
particle.bloomRadius |
14 |
Blur radius of the bloom pass, in CSS pixels. |
particleOpacity |
0.7 |
Global particle alpha. Kept under 1 so additive stacking does not clip hues to white. |
particleJitter |
1 |
Idle drift amplitude in pixels. 0 lets a settled chart stop drawing entirely. |
particle.jitterSpeed |
1 |
Idle drift speed. |
particleSpeed |
0.085 |
Spring stiffness toward the target, 0–1. |
particle.damping |
0.78 |
Velocity damping on that spring. |
particleShape |
'soft' |
soft adds a glow sprite once particles are large enough for the halo to read, dot keeps them bare, square forces rectangles. |
The particle count is derived from the plot area, the density and the size —
smaller particles are issued in greater numbers, and area counts sub-linearly so
a wide chart does not cost proportionally more. particleCount is the ceiling,
not the target.
Particles composite with lighter, so overlapping ones brighten each other.
That is what produces the glow, but stack enough of them and every channel
clips at 255 and the color turns white. Two things follow:
particleOpacitydefaults to 0.7, not 1, to leave headroom.- Line strokes are capped by area. A stroke is a 3px band, so a large budget would pile particles a dozen deep inside it and the series would lose its color. Each stroke takes only the particles its own area can carry; on an area chart the surplus goes to the fill, and on a plain line chart it is simply not spent.
If you pick your own colors, prefer ones with at least one channel well below
255 (#2ff0d6 keeps its red at 47, so it stays cyan under stacking). A color
whose channels are all high — a pastel or a light violet — will wash out to
white wherever the chart is dense.
| Option | Default | Description |
|---|---|---|
showAxis |
true |
Axis lines and tick labels. |
showGrid |
true |
Master switch for grid lines. |
axis.grid |
true |
Value-axis grid lines. |
axis.xGrid |
false |
Category-axis grid lines. On by default for bubble. |
axis.xLabels / axis.yLabels |
true |
Tick labels per axis. |
min / max |
null |
Pin the value axis. |
beginAtZero |
true |
Pull the value domain to include zero. false by default for bubble. |
ticks |
5 |
Approximate tick count; nice-number rounding picks the real one. |
valueFormat |
compact | fn(value) → string for ticks, labels and tooltips. The default abbreviates with SI suffixes (1.2k, 3.4M). |
xTitle / yTitle |
'' |
Axis titles. |
fontFamily |
system sans | Typeface for axis labels. |
axis.fontSize |
11 |
Size for axis labels. |
| Option | Default | Description |
|---|---|---|
showLegend |
true |
Rendered only when there is more than one series or slice. |
legendPosition |
type-driven | top, bottom, left, right. Defaults to bottom where color keys a series (line, area, bar, bubble, radar) and top for pie and donut. |
legendAlign |
type-driven | start, center, end. Follows legendPosition: center under the plot, start otherwise. |
legend.interactive |
true |
Click or key a legend entry to mute that series. |
legend.markerSize |
8 |
Swatch diameter, px. |
legend.fontSize |
12 |
Legend text size, px. |
showTooltip |
true |
Hover tooltip and crosshair. |
tooltip.format |
null |
fn(payload) → HTML string, where payload is { title, entries: [{ name, value, color }], x, y }. |
showValues |
false |
Print values next to the marks. |
| Option | Default | Applies to |
|---|---|---|
curve |
'smooth' |
line — smooth, linear, step. |
fillArea |
false |
line — type: 'area' turns it on. |
lineWidth |
3.2 |
line — thickness of the particle band forming the stroke. |
line.areaAmount |
0.55 |
line — share of particles spent on the fill, plus whatever the stroke cap frees up. 0.7 for type: 'area'. |
line.areaFade |
0.9 |
line — how much the fill thins toward the baseline. |
showPoints |
true |
line — particle clusters at each data point. |
line.pointRadius |
4.5 |
line — radius of those clusters. |
stacked |
false |
bar |
horizontal |
false |
bar |
barPadding |
0.3 |
bar — gap between categories, 0–1 of the band. |
bar.groupPadding |
0.16 |
bar — gap between bars inside a group, 0–1 of the slot. |
bar.fade |
0.45 |
bar — how much the fill thins toward the growing end. |
bar.radius |
4 |
bar — rounded cap radius. |
minRadius |
5 |
bubble — smallest bubble radius in pixels. |
maxRadius |
30 |
bubble — largest bubble radius. Values map between the two by area. |
bubble.edgeFade |
0.35 |
bubble — feathering of the rim, 0–1. |
bubble.outline |
false |
bubble — ring every bubble behind its cloud. The hovered one is ringed either way. |
bubble.minValue / maxValue |
null |
bubble — pin the size domain instead of taking it from the data. |
levels |
4 |
radar — web rings between the center and the edge. |
webShape |
'polygon' |
radar — polygon follows the spokes, circle draws true rings. |
radar.width |
2.6 |
radar — thickness of each series' outline band. |
radar.fill |
true |
radar — fill the enclosed area with particles. |
radar.fillAmount |
0.55 |
radar — share of particles spent on the fill. |
radar.fillFade |
0.55 |
radar — how much the fill thins toward the center. |
radar.points / pointRadius |
true / 4 |
radar — particle clusters at each vertex. |
radar.startAngle |
-90 |
radar — degrees; -90 puts the first spoke straight up. |
innerRadius |
0 / 0.62 |
pie / donut — hole size as a fraction of the outer radius. |
startAngle |
-90 |
pie — degrees; 0 is 3 o'clock. |
padAngle |
1.2 |
pie — gap between slices, in degrees. |
pie.radius |
0.95 |
pie — shrink factor against the space left once labels are placed. |
pie.edgeFade |
0.25 |
pie — feathering of the outer edge, 0–1. |
pie.labels |
'percent' |
pie — percent, value, label, none. Shown when showValues is on. |
pie.center |
'auto' |
pie — total in the donut hole, or none. auto means a total when there is a hole to put it in. |
A few defaults are decided by type at construction, and only when you have not
set them yourself:
| Type | What changes |
|---|---|
area |
line.area: true, line.areaAmount: 0.7. |
donut |
pie.innerRadius: 0.62. |
| line, area, bar, bubble, radar | Legend moves to bottom, center — color keys a series there, so the legend reads after the plot. |
| pie, donut | Legend stays top, start — color keys a category, so it reads more like a label list. |
bubble |
beginAtZero: false and axis.xGrid: true. A cloud is read by position, not height: a zero baseline strands it in a corner, and vertical-only gridlines give it nothing to line up against. |
Light and dark are one option. theme sets the chrome — axis lines, grid,
tick labels, crosshair, legend text and the tooltip — in one go:
new ParticleChart('#chart', { data, theme: 'light' });| Token | dark (default) |
light |
|---|---|---|
axisColor |
rgba(255,255,255,0.2) |
rgba(22,26,34,0.28) |
gridColor |
rgba(255,255,255,0.1) |
rgba(22,26,34,0.12) |
textColor |
rgba(255,255,255,0.6) |
rgba(22,26,34,0.62) |
crosshairColor |
rgba(255,255,255,0.22) |
rgba(22,26,34,0.3) |
legend.color |
rgba(255,255,255,0.72) |
rgba(22,26,34,0.78) |
tooltip.background |
rgba(12,14,19,0.94) |
rgba(255,255,255,0.96) |
tooltip.color |
#e9edf3 |
#161a22 |
tooltip.borderColor |
rgba(255,255,255,0.12) |
rgba(22,26,34,0.14) |
Both objects are exported as themes.dark and themes.light if you want to
read or extend them.
Any color you set explicitly outranks the theme, and survives later
setOptions calls — switching theme is the one thing that repaints chrome you
have already overridden, and colors passed in the same call as the theme
still win:
chart.setOptions({ theme: 'light', gridColor: 'rgba(0,0,0,0.06)' });To follow the reader's OS setting, and keep following it:
const media = matchMedia('(prefers-color-scheme: light)');
const sync = () => chart.setOptions({ theme: media.matches ? 'light' : 'dark' });
media.addEventListener('change', sync);
sync();Two things theme deliberately does not touch:
- Particle colors. The default palette is chosen to hold up on either ground. If you pass your own neons, they will want darkening for a white page.
background. The canvas stays transparent, so the chart sits on whatever your page already is.
Bloom is additive, and additive light on a white page only washes the hue out. On a light ground, trade it for opacity:
chart.setOptions({ theme: 'light', particleBloom: 0.15, particleOpacity: 0.9 });chart.update(data, options); // swap the data; particles morph to the new shape
chart.setOptions(options); // restyle without touching the data
chart.resize(); // force a re-layout
chart.stop(); // pause the render loop (e.g. during a scroll)
chart.start(); // resume it
chart.toggleSeries(key); // mute/unmute — the same thing the legend does
chart.toDataURL(); // PNG snapshot of the current frame
chart.destroy(); // stop the loop, drop listeners, remove the DOMupdate() reuses the existing particles wherever it can, so a data change reads
as a morph rather than a redraw. destroy() is idempotent.
Readable properties: chart.container, chart.root (the element the library
inserts), chart.options (fully resolved), chart.data (normalized to
{ labels, xValues, series }), chart.plot ({x, y, w, h} of the plot area)
and chart.destroyed.
import {
ParticleChart, // the factory — works with or without `new`
line, area, bar, bubble, radar, pie, donut, // shorthands: fn(target, data, options)
Chart, LineChart, BarChart, // the classes, if you want to extend one
BubbleChart, PieChart, RadarChart,
defaults, // the resolved default options object
palette, // the built-in categorical palette, in slot order
themes, // { dark, light } chrome palettes
version // '1.0.0'
} from 'particle-charts';The same names hang off the ParticleCharts global from the CDN build.
TypeScript declarations ship in the package; the option and data types are exported too, so you can build a config before you build a chart:
import { ParticleChart, type ChartOptions, type ChartData } from 'particle-charts';
const options: ChartOptions = { type: 'bar', stacked: true, theme: 'light' };
const data: ChartData = { labels: ['Q1', 'Q2'], values: [42, 58] };
const chart = new ParticleChart('#chart', { ...options, data });The default palette is a validated categorical set for dark surfaces — fixed slot order, chroma floor, and adjacent-pair color-vision separation all checked (worst adjacent CVD ΔE 8.4, normal-vision 19.3, contrast ≥ 3:1):
#3987e5 #d95926 #199e70 #c98500 #d55181 #008300 #9085e9 #e66767
Slots are assigned in order and never shuffled, because the ordering is the safety mechanism. Past eight categories, color stops being a reliable way to tell series apart — fold the tail into an "Other" series or use small multiples. The library warns once if you go past it.
Per-series colors override the slot ({ name: 'Revenue', color: '#3987e5' }),
and particleColor overrides the palette wholesale — as a single color, a list
assigned by slot, or fn(index, series).
The demo page deliberately overrides this with a neon teal (#2ff0d6), one
violet (#9085e9) for second series, and a single-hue teal ramp for
part-to-whole charts. That is a house style for that page, not a
recommendation — a neon sits well above the lightness band a categorical set
normally holds to. The pair still clears color-vision separation (worst ΔE
23.7) and contrast (≥ 3:1), the pie slices are sorted by size so the ramp
encodes magnitude, and every chart there also carries a legend, direct labels,
or both.
- Every chart renders a visually hidden data table for screen readers, and the
canvas carries a descriptive
aria-label. - A legend is always present for two or more series, so identity is never
carried by color alone. Legend entries are real buttons — focusable, with
aria-pressed— and clicking one mutes that series. prefers-reduced-motion: reducedisables the entrance flight and the idle drift. The chart still draws; it just stops moving.- Charts pause when the tab is hidden or they scroll out of view.
The library injects one small stylesheet, once, for the DOM parts — the legend
and the tooltip. The class names are stable and yours to restyle:
.pchart-root, .pchart-plot, .pchart-canvas, .pchart-legend,
.pchart-legend-item (plus .is-muted), .pchart-legend-marker,
.pchart-tooltip (plus .is-visible, -title, -row, -swatch, -name,
-value) and .pchart-a11y for the screen-reader table.
npm install # devDependencies only — esbuild (minify) and typescript
npm run build # bundle src/ -> dist/ (.js, .cjs, .min.js, .esm.js)
npm test # 115 tests, headless, no browser required
npm run typecheck # index.d.ts under --strict
npm start # build + serve the demo on :4173dist/ is committed so the demo and the CDN work straight from a clone; CI
fails the build if it drifts from src/, and runs the suite on Node 18, 20 and
22. Releases go out by tag — npm version minor && git push --follow-tags —
which runs the tests and publishes with npm provenance.
scripts/build.js is a ~140-line bundler: it concatenates the ES modules in
dependency order and wraps them in a UMD shell. It enforces the two rules that
make that safe — single-line named imports only, and no duplicate top-level
names across modules — and fails the build if either is broken.
The test suite runs against a DOM and Canvas2D stub (scripts/dom-stub.js), so
it exercises layout, particle target generation, the physics step, hover
hit-testing and teardown without a browser.
Particles are drawn one arc + fill each, snapped to the device pixel grid.
Measured at 30k particles a frame: 9.0ms for circles, 8.1ms for bare
rectangles, 82ms for drawImage of a pre-rendered sprite (even unscaled — the
per-call overhead is about ten times a fill), and 23ms for the whole field
batched into one Path2D. Circles cost about 11% over squares, so particles are
circles. Idle drift comes from a sine lookup table rather than Math.sin, which
matters at tens of thousands of particles a frame.
A chart stops scheduling frames entirely once its particles arrive, provided
particleJitter is 0 — so a static chart costs nothing.
With drift enabled it keeps animating by design, but not at full rate: once the particles have settled the only motion left is a slow sine wobble, so it is repainted at ~30fps rather than at the display's rate. On a 120Hz screen that is a quarter of the work, for a difference nobody can see. Frames in flight — the entrance, a data change, a resize — are never throttled.
Two other things happen for you. Charts pause completely when the tab is hidden
or when they scroll out of view (pauseWhenHidden, on by default, via
IntersectionObserver). And the axis spec — every tick label formatted and
measured against the canvas font — is rebuilt on layout, not on every frame;
measureText is one of the slower Canvas2D calls and none of its inputs change
between layouts.
If you have several charts on a long page, pausing them while the user scrolls is still worth it, since scrolling is when the main thread is busiest:
let t = null;
addEventListener('scroll', () => {
if (t === null) charts.forEach((c) => c.stop());
else clearTimeout(t);
t = setTimeout(() => { t = null; charts.forEach((c) => c.start()); }, 140);
}, { passive: true });If a chart feels sparse or heavy, particleDensity is the one knob to turn — it
scales the particle budget linearly, and the particle count is what the frame
cost is made of. At the default of 15 a 600x320 plot is worth roughly 19k
particles; at 5 it is roughly 6k, which still reads as a solid shape.
Any browser with ResizeObserver and Canvas2D — Chrome/Edge 64+, Firefox 69+,
Safari 13.1+. IntersectionObserver and ctx.filter are used when present and
degraded gracefully when not (no offscreen pausing, single-pass bloom).
Built with Particle Charts:
- DrawDojo — gamified app for learning perspective drawing.
Want your project listed? Email blake@destinedstudio.com.
Enjoying the library? Please consider buying me a coffee ☕
MIT © Blake Williford of Destined Studio.