Word Cloud
A four-state word cloud that sizes terms by the square root of their count and packs them along a deterministic spiral, with every word a focusable button and a ranked list for screen readers.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-word-cloud.jsonPrompt
Build a React + TypeScript + Tailwind "ChartWordCloud" card in plain SVG with
zod. No charting library: a word cloud is a text-packing problem, and no
recharts primitive lays overlapping boxes out along a spiral.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string; unit?: string;
terms: { id: string; text: string; value: number >= 0; group?: string }[] }
Component props = z.infer of that, plus description, scale ("sqrt" |
"linear" | "rank", default "sqrt"), maxTerms (60, clamped 1-200),
minFontSize (12, clamped 6-28), maxFontSize (42, clamped 16-96 and forced to
at least the floor + 2), padding (5, clamped 0-16), spiralPitch (7, clamped
2-24), ungroupedLabel, locale ("en-US"), formatValue, onSelect, onRetry,
className, and the remaining native div props spread on the root.
- NEGATIVE COUNTS ARE REFUSED at parse time, not mapped. A term cannot occur
-5 times, and the two things people reach for a negative frequency to mean
(sentiment, delta against a baseline) are a SECOND dimension. Carry that in
`group` and let colour say it, so "bigger" never has to be read as "more
negative".
- Ship a pure preparation function beside the schema:
prepareWordCloudTerms(terms, { scale, maxTerms, ungroupedLabel }) returning
{ ranked, stats }. It dedupes by id (first wins), drops blank text and
negative or non-finite counts, sorts by count descending with ties broken by
text and then id using PLAIN CODE-UNIT COMPARISON — localeCompare would
order equal counts differently per visitor, and here the sort order IS the
layout order, so two visitors would be handed two different pictures. Then
it cuts to maxTerms and emits { rank, share, weight } per term and
{ total, kept, invalid, duplicates, overflow, min, max, uniform, groups }
for the set.
Behavior
- SIZE. weight is 0..1 and the renderer maps it with
fontSize = minFontSize + weight * (maxFontSize - minFontSize).
sqrt weight = sqrt(value / max) <- default
linear weight = value / max
rank weight = 1 - (rank - 1) / (kept - 1)
Take the square root because font size is a LINEAR measure while the ink a
word lays down grows with its SQUARE: the advance width and the cap height
both scale with the size, so area goes as size^2. Only under sqrt does
"twice the area" mean "twice as often". linear makes a term mentioned twice
as often look four times as loud. rank throws magnitude away entirely and is
the rescue for a set where one term is 100x the rest — under sqrt that whole
tail lands on the font floor and stops ranking itself.
- The floor is an ADDITIVE offset, which is also what breaks the area
proportion: at minFontSize 0 the areas are exactly proportional and the tail
is unreadable; at 12 the tail reads and a term with a hundredth of the top
count is painted rather more than a hundredth of the area. Same trade as a
minimum bar width — legibility bought with accuracy. Say so in the footer
and keep the exact number in the readout and the ranked list.
- DEGENERATE SIZES, both deliberate. Exactly one kept term gets weight 1: it
is trivially its own maximum, and it is also the single-category-at-100%
case. Two or more terms with identical counts all get weight 0.5, and the
footer says out loud that size carries no information here. Do not let the
zero-anchored formula hand every term the maximum in that case — forty words
at 42px fit in no box.
- MEASURING TEXT. Advance widths decide the packing and they are not available
during SSR, so do both, in this order. (1) Estimate from character classes:
CJK and full-width one em, space 0.28, M W m w 0.85, I J f i j l r t and
punctuation 0.31, A-Z 0.66, digits 0.57, everything else 0.53. It runs
identically on the server and in the browser, so the markup hydrates without
a mismatch. Treating every character as one average advance is off by about
30% on an all-caps run and by 100% on a Chinese phrase, which is the
difference between a tidy cloud and one with words sitting on top of each
other. (2) After mount, read the real numbers: render one hidden <text> per
DISTINCT string inside the same <svg> at a fixed reference size — use
visibility:hidden, never display:none, which removes the element from the
render tree and makes its text length zero — call getComputedTextLength(),
divide by the reference size and keep the ratio. The ratio is
size-independent, so one measurement serves that word at any font size. The
measuring layer must carry the SAME font-affecting classes as the painted
words — semibold is wider than regular, so measuring at weight 400 and
drawing at 600 under-reserves every single box and the cloud overlaps.
Re-read once on document.fonts.ready, because a fallback face and the real
face have different metrics and a layout computed from the fallback leaves
visible gaps. Compare against the previous map before committing, or the
effect's own setState re-renders forever.
- OVERFLOW, in two steps. A word wider than the plot first shrinks until it
fits, but never below minFontSize. If it is still too wide at the floor,
clip it and append an ellipsis, budgeting the ellipsis first and using the
string's average per-character advance for the prefix — an approximation,
rounded down on purpose. The untouched spelling stays in the accessible name
and in the ranked list. Budget the packer's padding out of the plot width
before either step, or a word that exactly fills the plot ends up one
padding too wide and is reported as unplaceable.
- LAYOUT is a greedy spiral pack and it is entirely deterministic: no
randomness, no clock, no rotation. Words are consumed biggest first, so the
most frequent term lands at the exact centre and "closer to the middle"
becomes a real second encoding of rank instead of an accident. For each
word, walk an Archimedean spiral r = a * theta with a = spiralPitch / 2pi,
stretched on x by the plot's aspect ratio so a wide box is swept as evenly
as a square one, and take the first sample where the word's box, inflated by
padding, lies inside the plot and overlaps nothing already placed. The
overlap test is axis-aligned: x0 < ox1 && x1 > ox0 && y0 < oy1 && y1 > oy0.
- STEP THE SPIRAL BY ARC LENGTH, NOT BY ANGLE. An Archimedean spiral's arc
length is about (a/2) * theta^2, so theta = sqrt(2s/a) advances a constant
distance per step. A constant ANGULAR step samples the outer turns tens of
times more sparsely than the inner ones — exactly where the small words are
still hunting for a gap — so they get reported as "did not fit" while
sitting next to plenty of free space.
- STOPPING. A sample is inside the plot iff |r * aspect * cos| <= limitX and
|r * sin| <= limitY; divide the first by aspect and the largest radius any
angle can still satisfy is hypot(limitX / aspect, limitY). Break there.
Inside that radius, skip an out-of-bounds sample with continue and NEVER
break: the spiral leaves and re-enters the plot on every turn, and the gap
this word needs may be on the way back in. Refuse a box bigger than the plot
up front rather than spending 3,000 steps discovering it. Cost is
O(words x steps x placed) in the worst case, so memoise the whole scene on
the data, the font range, the padding and the pitch — it must run once per
data change, never once per hover.
- A term that finds no spot is NOT dropped: count it, name the first few in
the footer, and mark it in the ranked list. Same for terms cut by maxTerms,
duplicated ids and refused rows — everything the picture omits is stated
somewhere a reader can find it.
- The four states are first-class branches of one bg-card panel: a pulsing
skeleton of word-shaped rects (aria-hidden, fixed coordinates, no
randomness) plus one sr-only role="status" line; an empty state; an error
state whose "Try again" button appears only when onRetry exists; and ready.
Ready with nothing left to rank is a FIFTH branch with its own sentence —
"the extraction ran and matched nothing" is not "the extraction has not run".
Rendering & styling
- Semantic tokens only: bg-card, text-card-foreground, border,
text-muted-foreground, text-destructive, bg-muted / fill-muted, stroke-card,
stroke-foreground, ring, and var(--chart-1..5) for the terms. cn() merges
the consumer's className into the root; the component forwards its ref and
spreads the rest of the native div props.
- COLOUR IS NEVER THE ONLY CHANNEL. When at least one term carries a group,
colour means the bucket AND each bucket also gets its own underline dash
(solid, dashed, dotted, dash-dot, long-dash) drawn under the word at its
exact advance width — a pattern survives greyscale, colour blindness and a
bad projector in a way five hues do not. When nothing is grouped, colour is
the rank quintile, which size already says, so it is redundant by
construction and a colour-blind reader loses nothing.
- INK SCALES WITH SIZE. The five --chart-* tokens are tuned to clear 3:1
against the card in both themes, which is the bar for large text (24px and
up) and for non-text marks — but a cloud's tail is 12px text, where the bar
is 4.5:1, and --chart-1 computes to about 3.6:1 on the light card. So mix
the token toward --foreground as the word gets smaller: the full token at
24px and above, falling to 70% at the floor, where --chart-1 computes to
roughly 6.6:1 light and 7.5:1 dark. Mixing toward --foreground always
travels away from the surface — darker under the light theme, lighter under
the dark one — so one formula fixes both themes. Never mix toward --card:
that walks the ink into the surface it has to be read against.
- RESPONSIVE without measuring the container: one fixed viewBox, width 100%,
height auto, an explicit CSS aspect-ratio so a flex parent cannot collapse
it to zero, and preserveAspectRatio="xMidYMid meet". Every geometric number
is in viewBox units, so the whole cloud scales as one piece; below the
nominal width the text scales down with it, which is the honest trade for
keeping the layout intact, and the ranked list is what carries the data on a
phone.
- ACCESSIBILITY, which is the part a canvas word cloud cannot do at all:
* every word is a <text role="button"> under a roving tab stop, with an
aria-label carrying text, count, share, rank and group, so the picture is
operable and no number is trapped in pixels;
* the <svg> is role="group", NOT role="img" — img is
children-presentational and would hide every focusable word inside it from
the very readers those labels are written for;
* aria-describedby points at an sr-only paragraph stating the actual
finding: how many terms, the total, the leader with its count and share,
what the top five add up to, which scale is in force, the buckets, and how
many terms are missing from the picture;
* an sr-only ordered list repeats every kept term with its exact count and
share and marks the ones that were not drawn. Put sr-only on the WRAPPER;
if you swap the list for a table, keep it on the wrapper, because CSS
width is only a lower bound for a table box, so width:1px does not hold
one back and a narrow viewport picks up hundreds of px of horizontal
scroll;
* the visible readout line under the cloud is aria-hidden on purpose: the
focused word already announces all of it, and a live region would say
every word of it twice.
- KEYBOARD. One tab stop into the cloud. ArrowRight / ArrowDown next term,
ArrowLeft / ArrowUp previous, Home / End first / last, Enter or Space fires
onSelect. The arrows walk the RANK order, not the geometry: a cloud's
spatial arrangement carries no meaning, so navigating it spatially would be
navigating noise. preventDefault only after a key you actually handled, or
the chart eats the page's own scrolling. Focus and hover share one
indicator, drawn last so a neighbour cannot bury it: two stacked strokes,
--card under --foreground, so it stays visible over any ink in either theme.
- Guard onSelect with event.detail > 1 rather than a timer — it reads the
click count straight off the event, so a double click fires once and there
is nothing to schedule or clean up. The only asynchronous work in the whole
component is the font measurement, and its effect cancels with a flag on
unmount and on any change to the term list.
- Motion is decoration here: the skeleton pulses with
motion-reduce:animate-none and every transition carries
motion-reduce:transition-none. Nothing about reading, focusing or picking a
word depends on motion.
Customization levers
- scale: "sqrt" for anything that will be read as a measurement, "rank" when
one term swamps the set or when the tail's own order is the story, "linear"
only for a deliberately dramatic headline number.
- minFontSize / maxFontSize: the legibility-versus-honesty dial. Lower the
floor toward 0 for a strictly area-proportional cloud; raise it for a
headline card with a handful of terms; narrow the range and the cloud reads
as a tag list rather than as a ranking.
- maxTerms: how much tail is worth drawing. Past roughly 120 terms a cloud is
texture rather than information — cut earlier and let the ranked list carry
the rest.
- padding and spiralPitch: the density dial. A small padding with a small
pitch packs tightly and costs more collision tests; larger values give an
airier cloud that holds fewer words.
- Palette: re-point the five --chart-* slots and the words and the legend
follow together; change the 70% ink floor to trade hue for contrast; drop
the group colouring and keep only the dashes for a monochrome brand.
- Grouping: any second dimension can ride on `group` — sentiment, source,
language, time bucket — and the legend and the dashes come along free.
- Interaction: onSelect is the drill-in hook (filter a table, open the
matching tickets). A hovered-group dim, or a legend that filters, keys off
the term's group without touching the layout at all.Concepts
- Square-root sizing — font size is a linear measure but a word's ink grows with its square, so
size ∝ √countis what makes painted area track frequency. Under a linear scale a term said twice as often looks four times as loud; the sqrt is not a smoothing trick, it is the correction that makes the picture mean what it looks like. - Deterministic spiral placement — words are packed biggest-first along an Archimedean spiral with an axis-aligned overlap test, with no random seed and no clock anywhere in the path. The same counts always produce the same picture, which is what makes two screenshots of two weeks actually comparable.
- Arc-length stepping — the spiral advances a constant distance per sample, not a constant angle. Equal angles sample the outer turns far more sparsely than the inner ones, so the small words that live out there get reported as "did not fit" while sitting beside free space.
- Measure, then re-layout — the first paint packs from per-character-class advance estimates, which are identical on the server and in the browser and so hydrate cleanly; a hidden layer then measures the real glyphs once, and once again after webfonts land, and the layout is recomputed from the true numbers.
- Nothing vanishes quietly — terms cut by
maxTerms, terms that found no free space, duplicated ids, blank strings and refused negative counts are each counted, and each is either named in the footer or marked in the ranked list. A cloud that silently drops its tail is the failure mode this one is built to avoid. - Ranked list as the text alternative — a cloud is unreadable to a screen reader by construction, so the real deliverable is the sr-only ordered list plus a summary that states the finding, backed by per-word buttons that announce count, share and rank. The colours are backed up by underline dashes for the same reason: nobody should need one channel to read this.
Hexbin Density Plot
A four-state hexbin that bins the raw scatter itself — nearest-centre binning, sqrt / linear / quantile bands, colour paired with hexagon size, a live bin-radius control and a ranked screen-reader table.
Venn Diagram
An area-proportional Venn for two or three sets that measures what it drew and falls back to a labelled region table when circles cannot tell the truth.