← back to writing
Cover image for Introducing microcharts — word-sized charts for React

Introducing microcharts — word-sized charts for React

Why I built microcharts — 106 word-sized chart types for React. Zero dependencies, interactive at ~2–7 kB, accessible by default, small enough to live inside a sentence.

Here’s a sentence with a chart in it: signups doubled since March, and deploys kept their rhythm through all of it. Those are live — hover one, or click a point and the readout pins.

That’s the whole idea: charts that sit inside a sentence, a table cell, or a card, on the text baseline, without pushing the layout around. This post is about why I built microcharts — an open-source library of 106 word-sized chart types for React — and the constraints behind it.

The itch

Every chart library I’ve used assumes the chart is the page. You reach for one because you need a small trend next to a revenue number, and you get axes, legends, a tooltip engine, a resize observer, and 40 kB of runtime before the first line is drawn. So in real products we give up and type ”↑ 12%” instead.

But look where data actually lives in an interface: a table cell. A KPI card. An alert row that says error bursts are spiking Error bursts on one service. A match header where the win probability Win probability90% tells the whole story. Tufte named this decades ago — the sparkline, a “data-word” — and the web mostly built dashboards instead.

Here’s a sampler of the catalog, rendered by the actual library:

sparkline
sparkbar
Up 12.4%.
delta
bullet
status-dot
activity-grid
seismogram
heat-strip
slope
dumbbell
waterfall
mini-bar
segmented-bar
micro-donut
win-prob-worm
city-skyline
waveform
ohlc
progress-ring
tally-marks
+86the other 86 →
20 of the 106 types — the shipped components, drawn in this site's palette, not screenshots. All interactive: hover or arrow keys to activate a unit, click or Enter to pin its readout, Escape to clear.

Constraints as the product

The library is really five constraints, each enforced mechanically so it can’t erode:

Zero runtime dependencies. dependencies: {} — React is the only peer. No D3, no chart engine. Scales, path builders, stats, color math, and the summary generator are all in-house. That’s how a fully interactive chart lands at ~2–7 kB gzip (measured 1.88–6.64, median 5.03) and its static twin at ~1–4 kB. Every chart carries its own size budget, and a chart that gets fat fails the build.

One grammar. Every chart renders from data alone; the same prop means the same thing on all 106 types. Variants are props. Annotations are children:

import { Sparkline } from "@microcharts/react/sparkline";
import { Threshold } from "@microcharts/react/annotations";

<Sparkline data={revenue} title="Weekly revenue" dots="auto">
  <Threshold y={5} />
</Sparkline>;

A new data shape is a new component; everything else is a prop. Learn one chart and you’ve learned the catalog.

Interactive when you ask, static when you don’t. Import from /interactive and every chart shares one contract: hover or arrow keys make a unit active; click, tap, or Enter selects it and pins the readout; Escape clears. onActive/onSelect hand you { index, value, formatted }, and readout={false} moves the value into your own UI — a KPI card wired in one line:

Monthly recurring revenue
$62.4k
December
hover or arrow-key the line — the number follows

Drop /interactive from the import and you get the static twin: hook-free, listener-free SVG that renders in React Server Components with zero client JavaScript, at roughly 0.03 ms per chart on the server — 500 sparklines come back as HTML in about 6 ms. The interactive chart composes the static one, so the geometry can’t drift between them.

Every chart speaks. Each one is role="img" and writes its own accessible name from your actual data. This demo reads the sentence back out of the DOM you’re looking at:

That sentence is read from the rendered DOM — the literal aria-label the library just wrote for this data. Not hand-written, not copied from docs. Swap the data and it rewrites itself.

Nothing hand-written, nothing to drift when the data changes. The generator is fast enough (~890k sentences/sec) that it’s simply on by default. Reduced motion, forced colors, and print are part of each chart’s definition of done.

Honest encodings. Every type has one documented encoding channel and a lie factor of 1. Delight is allowed — has a little personality — but it never changes what the data means. It’s also why a few classics aren’t in the catalog: a pie or a needle gauge stops reading honestly at word size, so the catalog carries shapes that do — progress against a target is a bullet, Progress vs target not a speedometer.

In a real interface

The real test is a surface with ten questions and six hundred pixels to answer them in. This is a release-week console for a product team — the kind of widget that normally justifies a dashboard framework:

Rollout status checkout-v2 · release weekstaged rollout · 50% of traffic
mrr, 12 monthsinteractive$62.4k +19% YoY
signup funnelinteractive9% visitor → paid — the leak is activation
w12 cohort retentioninteractiveplateaus at 38% — healthy for self-serve
quarter forecastinteractive$45k target inside the 80% band
checkout latency · a/binteractivev2 ~14 ms faster at the median
traffic mix, 12 weeksinteractivemobile crossed web in May
deploys, last 19interactivetwo reds Thursday — both rolled back clean
checkout slo · 30d budgetinteractive62% left — spend it on the next migration
release dayinteractiveone alert, held two hours, resumed

Ten chart types, one story, no dashboard framework — a CSS grid where every cell answers one question. Every panel here is the interactive build: hover or arrow keys to read values, click to pin. Each type also ships a static twin that renders the same geometry with zero client JavaScript.

Each cell is one question and the chart type built for it: a funnel for leaks, a retention curve for the plateau, a forecast cone for “will we land the quarter”, paired A/B distributions for the latency experiment, an error-budget line for “can we afford another deploy”. Ten types, ten imports, plain CSS grid.

Charts can also drive the UI around them. Scrub a row’s trend below and the “now” cell follows; pick a service and the detail chart re-renders with an SLO Threshold and an incident Marker passed as children, in data space:

service healthp95 latency · last 30 days
servicep95 trendnowwowerror budget
122 msapi error budget consumed
77 msweb error budget consumed
358 msworkers error budget consumed
175 mssearch error budget consumed
workers · detailSLO 400 ms · day 15: queue backup

Five chart types, one table. The row sparklines pipe their readout into the "now" column via readout={false} + onActive; click a service name for its annotated detail — a Threshold and a Marker passed as children, in data space. Every piece is a separate ~2–7 kB import; there is no "dashboard framework" here.

There’s no grid component, no chart config objects, no layout engine in either demo. Plain HTML where some cells happen to contain charts.

Charts in a streamed reply

The generated sentence had a side effect I didn’t plan. A chart is safe for a language model to write when three things hold: the grammar is small, data alone always renders something valid, and the output describes itself in words a person can check. Accessibility had already forced all three.

So the docs ship llms.txt and a machine-readable catalog.json, and the library defines a small text grammar for streamed replies — a backtick run for an inline chart, a fenced block for a standalone one:

The API recovered `microchart sparkline 12 14 13 17 16 19 22` overnight.

```microchart mini-bar Slowest builds (minutes)
web 14
api 9
mobile 21
docs 4
```

A short parser turns that into the shipped component the moment the fence closes. Watch it happen:

assistant

Watch the grammar mid-stream: it reads as plain code until the closing backtick — then it becomes the chart. Invalid or half-finished grammar stays honest text. Never a broken chart, never invented data.

Until the closing backtick arrives, the grammar stays plain text — and invalid grammar stays text too. The reader gets a real chart that carries its own description, or the honest raw characters. Never a broken chart, never invented data.

Theming

Everything visual routes through about two dozen --mc-* custom properties at zero specificity, so your styles always win. Presets (modern, editorial, mono, vivid, print, eink) are token bundles, and defineTheme derives a colour-blind-safe palette with dark variants from a single brand accent. The charts in this post are bound to this site’s palette — change the accent in the corner, or flip the theme, and they follow.

Getting started

npm install @microcharts/react
import "@microcharts/react/styles.css"; // once, at the root
import { Sparkline } from "@microcharts/react/sparkline";

<Sparkline data={[3, 5, 4, 8, 6, 9]} title="Weekly revenue" />;

Each chart is its own subpath import, so you ship only the types you use. If you’d rather delegate, the quickstart has a paste-in prompt for coding agents that installs the package, reads llms.txt and catalog.json, and records the conventions in your AGENTS.md.

Numbers you can check

The sizes, timings, and quoted sentences above regenerate from the repository, and the docs are tested against the real output — a claim that stops reproducing breaks the build. Seven small example apps (a product-analytics dashboard, a finance ledger, an AI eval console among them) install the package from npm and run all 106 types in real product contexts; they’re linked from the microcharts.dev homepage.

If you ship a chart where a word used to be, show me — I’m @ganapativs everywhere.