WordCloud

Frequency-weighted text cloud.

Live demo

01

Organic β€” flex-wrap line flow

  • svelte
  • reactivity
  • rune
  • kit
  • snippet
  • effect
  • store
  • state
  • derived
  • props
  • css
  • transitions
  • animation
  • router
  • forms
  • typescript
  • vite
  • ssr
  • hydration
  • preprocess

Grid β€” typographic poster (alternating rotation)

designengineeringresearchproductanalyticssupportmarketingoperationsfinance

Radial β€” concentric rings (seeded random rotation)

contexttokenpromptagenttoolmodelembeddingmemoryretrievalragfunctioninferencefine-tuneeval

Branded palette + click handler

Click any word to record a selection.

Implementation

02
WordCloud.svelte
<script>
  import WordCloud from '$lib/components/WordCloud.svelte';
</script>
​
<WordCloud
  words={tags}
  variant="organic"
  minSize={14}
  maxSize={48}
/>

WordCloud takes an array of {text, weight, href?} tuples and renders them with size proportional to weight via a clamped linear scale. Three pure-CSS variants share the same data shape: organic uses flex-wrap, grid uses CSS grid with auto-fit columns, and radial places words in concentric rings. Palettes are deterministic β€” a hashed word always renders the same colour. Zero canvas, zero D3.

Logic explainer

03

What Does It Do? (Plain English)

A frequency-weighted text cloud. Pass it [{ text, weight, href? }, …] and it renders the words sized proportionally to their weight, packed into a container, optionally rotated, coloured deterministically from a palette. Three variants share the same input shape: organic (flex-wrap chaos in a good way), grid (CSS-grid placement with weighted spans), and radial (concentric rings of words around a centre).

Use cases: blog tags, search facets, AI prompt-token frequency, customer-feedback sentiment, code-keyword analysis. Pure CSS layout β€” no canvas, no D3, no rAF. Same word always gets the same colour across renders because palette index is derived from a hash of the text, so re-mounts don't flicker.

How It Works (Pseudo-Code)

state:
  // No internal mutable state β€” everything is derived from props

derive normalisedWords (from words):
  // Sort by weight desc, deduplicate by lowercase text (first wins)
  // Filter out empty/non-string text
  return sorted, deduped array

derive weightExtents (from normalisedWords):
  return { min, max } over weights, with sane fallbacks for empty input

derive resolvedVariant     = pickVariant(variant)        // organic | grid | radial
derive resolvedRotation    = pickRotationStrategy(rot)   // none | alternating | random
derive resolvedMin / Max   = clampSize(minSize, maxSize) to [8, 200]

per-word (during render, not state):
  fontSize = scaleSize(weight, min, max, minPx, maxPx)   // linear, clamped
  color    = palette[hashWord(text) % palette.length]    // deterministic by text
  rotation = pickRotation(strategy, index, seed)         // 0 | -90 | small set

  if variant === 'radial':
    {left%, top%} = polarPosition(index)                  // index 0 at centre, then rings of 6, 12, 18…

render:
  <ul role="list">
    {#each normalisedWords}
      {#if href}    <a  role="listitem" style="font-size, color, transform">…</a>
      {:else if onWordClick}  <button …>
      {:else}                  <span aria-hidden …>
    {/each}
  </ul>
  {#if srTable} visually-hidden ranked <table> for screen readers {/if}

There's no measurement loop, no resize observer, no rAF. The whole layout is a single render pass: compute the per-word style values, hand them to CSS, let the browser do the placement.

Core Concept: Three Layout Strategies, One Data Shape

The interesting bit isn't the maths (it's modest) β€” it's that three quite different visual outcomes share the same input shape and the same scaling pipeline.

Linear weight β†’ font-size scale

fontSize(weight) = minSize + ((weight - minWeight) / (maxWeight - minWeight)) Γ— (maxSize - minSize)
                 clamped to [minSize, maxSize]

Linear, not log. For tag clouds the weights are usually within 1–2 orders of magnitude (a popular tag has 50 occurrences, a rare one has 3); linear scaling is honest and readable. Log scaling makes sense when weights span 4+ orders of magnitude (e.g., pageview counts), in which case you should pre-transform weight to log(weight) before passing it in.

When all weights are equal, the formula degenerates to 0/0. We collapse to the midpoint (minSize + maxSize) / 2 β€” uniform-size cloud, no division-by-zero exception.

Deterministic colour: hash the text

Most word-cloud libraries randomise colour, which means re-renders flicker. We hash the text with a tiny djb2-style 32-bit hash and modulo into the palette:

hashWord("svelte") β†’ 4_184_028_393 β†’ mod palette.length β†’ palette[1]

Same word, same colour, every time. Different cloud, different corpus β€” same word still gets the same colour, which is occasionally useful when you have multiple clouds side by side comparing eras of the same vocabulary.

Organic variant: flex-wrap chaos

The container is display: flex; flex-wrap: wrap; justify-content: center; align-items: baseline; with a small gap. Words are placed left-to-right, top-to-bottom, breaking into new rows when they run out of width. Optional rotation (-90deg) is per-word inline transform. The result is the recognisable "wordle" look: high-weight words anchor visually because they're physically larger, low-weight words fill the gaps.

Grid variant: CSS-grid with weighted spans

The container is display: grid with grid-template-columns: repeat(auto-fill, minmax(<minSize>px, 1fr)). Each word's grid-column span is proportional to its font size band, so heavy words occupy multiple cells while light ones occupy one. The grid algorithm handles placement deterministically β€” same input, same layout. Useful when you need predictable rows for screenshot stability.

Radial variant: polar coordinates per index

polarPosition(0)        = centre (50%, 50%)
polarPosition(1..6)     = ring 1, evenly spaced around 360Β°
polarPosition(7..18)    = ring 2, 12 positions
polarPosition(19..36)   = ring 3, 18 positions
…

Ring k holds 6k positions and sits at radius min(48%, 14% Γ— k) from centre. The positions are computed once per word at render time β€” no iteration loop, no collision detection. Tight at the centre, loose at the edges; word 0 (heaviest) sits in the bullseye.

The 0.85 vertical squash on the radius (top = 50 + sin Γ— radius Γ— 0.85) compensates for typical wide-aspect containers β€” without it, the radial cloud looks vertically cramped in landscape orientations.

Performance

Single-pass, GPU-friendly, scales to ~500 words before the DOM count starts to matter.

  • n ≀ 50: Trivial. No measurement, no layout thrash.
  • n = 50–200: One render. CSS does all the work.
  • n = 200–500: DOM has 200–500 list items. Still fine. Hover scale is transform, GPU-composited.
  • n > 500: DOM count rather than CPU is the limit. If you genuinely have 1 000+ tags to show, consider truncating to top-N before passing in (with a "show all" toggle).

There's no rAF loop, no observer, no event delegation. Hover transitions are pure CSS. The cost-per-frame is whatever the browser charges to scale-transform a single element on hover.

prefers-reduced-motion: reduce disables the hover scale transition. The cloud is otherwise static β€” no animations to suppress.

State Flow Diagram

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  empty / no words      β”‚  words === []
              β”‚  renders nothing        β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚ words prop set
                          β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  normalised            β”‚
              β”‚  - dedup by lowercase  β”‚
              β”‚  - sort by weight desc β”‚
              β”‚  - drop invalid items  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
                          β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  rendered              β”‚
              β”‚  per-word: size, color β”‚
              β”‚  rotation, position    β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ hover     β”‚ click         β”‚ words prop changes
              β–Ό           β–Ό               β–Ό
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚ scale    β”‚ β”‚ onWordClick  β”‚  β”‚ re-normalise     β”‚
       β”‚ transformβ”‚ β”‚ fired (or    β”‚  β”‚ re-render        β”‚
       β”‚ (CSS)    β”‚ β”‚ <a> follow)  β”‚  β”‚ colours stable   β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ across renders   β”‚
                                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Props Reference

Prop Type Default Description
words WordCloudWord[] [] Each word: { text, weight, href? }. Sorted/deduped automatically.
variant 'organic' | 'grid' | 'radial' 'organic' Layout strategy. Invalid strings fall back to 'organic'.
rotation 'none' | 'alternating' | 'random' 'none' Rotation strategy. 'random' is seeded by the seed prop.
minSize number (px) 14 Smallest font size. Clamped to [8, 200].
maxSize number (px) 48 Largest font size. Clamped to [8, 200].
palette string[] built-in Hex colours indexed by hashWord(text) % palette.length.
seed number 0 PRNG seed for 'random' rotation. Same seed β†’ same angles.
srTable boolean false Emit a visually-hidden ranked <table> for screen readers.
onWordClick (word: WordCloudWord) => void undefined Click handler. When set, words render as <button role="listitem">.
aria-label string 'Word cloud' Container ARIA label when words are decorative-only.
class string '' Extra classes appended to the container.

Edge Cases

Situation Behaviour
words === [] Container renders empty (no words). No errors.
Single word Centred at midpoint font size (extents collapse triggers midpoint fallback).
All weights identical Every word renders at (minSize + maxSize) / 2. Visual hierarchy disappears (which is correct β€” the data has no hierarchy).
Duplicate text (e.g. "Svelte" and "svelte") Lowercase deduplication keeps the first; the duplicate is silently dropped.
Word with empty/whitespace text Filtered out during normalisation.
weight non-finite (NaN, Infinity) Defaulted to 1 during normalisation.
minSize > maxSize The clamps still respect [8, 200] individually; the scale formula goes negative gracefully and clamps back. Layout looks weird but doesn't error.
palette empty or missing Falls back to DEFAULT_PALETTE (8 colours).
variant is a typo (e.g., 'wonderful') pickVariant returns 'organic'. The isValidVariant guard catches anything not in the union.
'random' rotation, same seed, two mounts Identical angles. The Mulberry32 PRNG is deterministic.
prefers-reduced-motion: reduce Hover scale disabled. Layout unchanged.
srTable: true A second, hidden ranked table is added to the DOM for screen-reader users. AT navigates the table; sighted users see only the cloud.

Dependencies

  • Svelte 5.x β€” $derived for normalisation and resolved-prop chains.
  • Zero external dependencies. Hashing, PRNG, scaling, polar placement, and palette indexing are all hand-rolled in <150 lines of pure-function code (each exported from the <script module> block for tests).

File Structure

src/lib/components/WordCloud.svelte    # implementation (incl. exported pure helpers in <script module>)
src/lib/components/WordCloud.test.ts   # unit tests covering hashWord, scaleSize, polarPosition, etc.
src/lib/components/WordCloud.md        # this file
src/routes/wordcloud/+page.svelte      # demo page

API

04
PropTypeDefaultDescription
wordsWordCloudWord[]requiredArray of { text, weight, href? }.
variant'organic' | 'grid' | 'radial''organic'Layout mode. Organic uses flex-wrap, grid uses CSS grid, radial uses polar rings.
rotation'none' | 'alternating' | 'random''none'Per-word rotation. Random uses seed for reproducibility.
minSize / maxSizenumber14 / 48Pixel range for the linear weight scale.
palettestring[]8-colour defaultHashed deterministically from word text.
seednumber0Seeds random rotation so every render produces the same angles.
onWordClick(w: WordCloudWord) => voidβ€”Renders words as focusable buttons. Without it words are spans (or anchors via href).
srTablebooleanfalseHides cloud from screen readers and emits a visually-hidden ranked table instead.
aria-labelstring'Word cloud'Accessible label for the cloud region.