Rendering word clouds with Canvas 2D

Word clouds are a classic way to show weighted text — but packing hundreds of words without overlap is surprisingly hard. This example walks through the full pipeline: you hand the engine a list of weighted terms, it returns positions in WebAssembly, and you paint them on a plain <canvas>. No bundler, no framework — just ES modules over HTTP.

Layout vs. render. The library computes where each word goes (x, y, rotation, fontSize). Your code decides how to draw it — colors, fonts, hover states, SVG, WebGL, or DOM. That separation is intentional: WASM does the expensive packing; you keep full control of the UI.

What happens on each call to layout():

  1. Weights → font sizes. Each item's weight is mapped into the [minFontSize, maxFontSize] range. Heavier words get larger glyphs.
  2. Text measurement. The wrapper measures each word with a canvas, in the font you pass as font, so the engine knows its real bounding box.
  3. Placement search. Words are placed one by one — spiral / grid search with collision detection against a bitmap occupancy mask. Mode controls how thorough that search is.
  4. Placements returned. You get plain JS objects back. Coordinates are the center of each word's axis-aligned bounding box, in canvas pixel space (origin top-left).

Step by step

Step 1

Prepare weighted items

Input is always an array of { text, weight }. Weights are relative — only their ratio matters. Sorting is optional; the engine re-orders by weight internally (largest first) so big words claim space before small ones.

data.js
/** @type {Array<{ text: string, weight: number }>} */
export const items = [
  { text: "WebAssembly", weight: 100 },
  { text: "Rust",        weight: 80  },
  { text: "layout",      weight: 40  },
  { text: "spiral",      weight: 25  },
  { text: "bitmap",      weight: 18  },
];

// Weights need not sum to 100 — { weight: 1 } and { weight: 1000 } behave the same
// as { weight: 0.001 } and { weight: 1 } after normalization.
Step 2

Call layout() with canvas dimensions

Pull in layout from the package entry point. The first call downloads and instantiates the WASM module; later calls reuse the cached instance. Pass the pixel size of the area you will draw into — typically canvas.width and canvas.height after you set them.

layout.js
import { layout } from "./js/index.js";
import { items }   from "./data.js";

const canvas = document.getElementById("cloud");
const width  = 960;
const height = 600;

// Set backing-store size before layout — layout uses these exact pixels.
canvas.width  = width;
canvas.height = height;

const placements = await layout(items, {
  mode:        "pretty",   // placement quality (see table below)
  width,                   // layout bounds — match canvas
  height,
  minFontSize: 14,           // smallest word in px
  maxFontSize: 88,           // largest word in px
  padding:     2,            // extra gap between word boxes (px)
  font: "system-ui, sans-serif", // the family you render with
});

// placements → [{ text, x, y, rotation, fontSize, baselineOffset }, ...]
// Not every input word is guaranteed to fit — check placements.length.
console.log(`placed ${placements.length} of ${items.length} words`);

Layout modes trade placement quality for speed:

ModeBehaviorWhen to use
"pretty" Thorough spiral search. Tightest, most organic clouds. Final visuals, < 2 000 words, screenshots.
"balanced" Middle ground between search depth and throughput. Interactive dashboards that re-layout on filter changes.
"fast" Row packing instead of a search. Places more words per millisecond. 5 000+ words, live previews, repeated re-layouts.
Step 3

Render placements on Canvas 2D

Each placement's x and y are the center of the word's bounding box — not the baseline and not the top-left corner. Set textAlign: "center" and textBaseline: "middle", then draw at (0, 0) after translating to (p.x, p.y). Rotation is in degrees — always 0 unless you pass rotate: "mixed90", which also emits 90; convert to radians for ctx.rotate().

render.js
/**
 * Draw all placements. Call after layout() resolves.
 * @param {CanvasRenderingContext2D} ctx
 * @param {Array} placements — from layout()
 * @param {string[]} [palette] — optional per-word fill colors
 */
export function drawWordCloud(ctx, placements, palette = ["steelblue"]) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  ctx.textAlign    = "center";
  ctx.textBaseline = "middle";

  for (let i = 0; i < placements.length; i++) {
    const p = placements[i];
    ctx.save();                          // isolate transform + style per word
    ctx.translate(p.x, p.y);              // move origin to bbox center
    if (p.rotation) {
      ctx.rotate((p.rotation * Math.PI) / 180);
    }
    ctx.font      = `${p.fontSize}px system-ui, sans-serif`;
    ctx.fillStyle = palette[i % palette.length];
    ctx.fillText(p.text, 0, 0);           // (0,0) = center because of align/baseline
    ctx.restore();
  }
}
Font mismatch. The engine has no font of its own, so pass the family you render with as options.font and it will be measured for you. Skip that and words are sized against a built-in guess at a generic sans-serif — the further your real typeface or weight is from that guess, the more the drawn glyphs overrun their reserved space, which reads as slight overlap at a word's first and last letters.
Step 4

Wire it together

A minimal page loads the module, sizes the canvas to its container, runs layout once, and draws. Re-call layout() whenever data or dimensions change (resize, filter, theme toggle).

main.js — complete entry point
import { layout }         from "./js/index.js";
import { items }          from "./data.js";
import { drawWordCloud }  from "./render.js";

const canvas = document.getElementById("cloud");
const ctx    = canvas.getContext("2d");

async function renderCloud() {
  const w = Math.floor(canvas.getBoundingClientRect().width) || 800;
  const h = Math.round(w * 0.62);
  canvas.width = w;
  canvas.height = h;

  const placements = await layout(items, {
    mode: "pretty", width: w, height: h,
    minFontSize: 14, maxFontSize: 72, padding: 2,
  });

  drawWordCloud(ctx, placements, [
    "#1f6feb", "#238636", "#a371f7", "#db6d28",
  ]);
}

renderCloud();
window.addEventListener("resize", () => { renderCloud(); });

Serve the project over HTTP (npx serve . works well) so import and the .wasm file load correctly.

Try it yourself

Use the controls below to run the pipeline on random datasets. The status line shows how many words fit, which layout mode ran, and how long packing took.

live canvas

Press Esc or click Exit fullscreen to return to the demo card.

Presets tune minFontSize / maxFontSize so words stay readable at each scale (50 → 16–88px, 500 → 10–52px, 5 000 → 6–16px + fast mode). Fullscreen lays out against the viewport so you can see the engine's maximum placement count.