GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Word Cloud JavaScript: 9 Scenes, Spiral to 3D

Word cloud JavaScript on canvas: if every new vote reshuffles the cloud, nobody can read it.

From Survey Summaries to a Live Poll Board — Nine Screens Where Words Find Their Place

Auto-plays · click a tile to jump to its section · all nine in one zip

A word cloud tallies how often each word shows up, draws the common ones large and the rare ones small, and packs all of them onto one board so that no two words touch. Left alone, each board here runs a seeded demo by itself: 01 to 05 drop words in one at a time and start over once the board is full, 06 inflates its bubbles on the first loop only and after that kicks them outward every 6 seconds and lets them pack back together, and 07 spins a sphere that leans toward a virtual pointer drawn as a cream ring. 08 sways in the wind without pause, and 09 feeds in 56 pre-drawn votes, one every 125 ms. The first time a real pointer moves over a canvas the demo flag comes off. From then on, 01 to 05 and 08 ring the word under the pointer and add a tooltip with its count (05 names its topic instead), 06 and 09 show the tooltip without a ring (09 gives the vote count), and 07 shows it only for words on the front of the sphere.

When weighted badges flowing in rows are all you need, the tech stack tag cloud already covers that. The difference here is position: nothing flows in rows, and every word computes a spot where its letters overlap none of the letters already on the board.

Turning Open-Ended Survey Answers Into One Picture

If you have a pile of open-ended survey answers and need to show at a glance what came up most, start with 01. It takes the answer sentences as written, splits and counts them, and sizes the words in one pass, so the 30 sample answers are the only part you swap for real ones. For retrospective feedback that should be grouped by theme, 05 fits better, because each topic gets its own color and its own anchor point.

A Poll Board for the Screen at a Talk

For audience votes during a talk or class, 09 is the board to adapt. Its button takes the next vote from the schedule and hands it to castVote, which raises the count, sets the new target size and checks whether the leader changed, so feeding in real responses appears to need nothing more than calling that function with an option index. The 14 option words are fixed from the start (3, 3, 2, 2 and 2 votes, then nine options with 1), so a board that accepts free text would need the counting step from 01 in front of it.

How Does Word Cloud Work, and Why Are the Biggest Words Placed First?

01 to 05 and 08 place words from the most frequent down. The other three skip that order: 07 scatters words over sphere slots shuffled by a seed regardless of size, 06 inflates bubbles one by one and lets forces tidy them, and 09 guards each option's existing spot.

The First Word Lands on Step One

On an empty board the first word finds room on the very first step of the spiral. In 02 the first word took 1 step and the next four took 139, 154, 112 and 162. Leave the big words for last and a 50 px word would face a board already peppered with small letters, so it would likely need a far longer walk or a few 0.85× shrinks, and in a picture where size stands for count, a shrunken leader states the ranking wrong.

Small Words Fill What Is Left

03 pushes this order all the way. After its 48 main words it writes the same words again at 11, 10, 9 and 8 px to plug the gaps between big letters, and it stops at 153 because the board is full. 04 also seats words from the deepest cell outward: the first one lands at the deepest point, 106.7 px from the edge, and the rim is left to the small words.

Size by Area, Place by Spiral — Square-Root Scaling of Counts and Bit-Grid Overlap Tests to Find a Free Spot

Twice the Count, Twice the Area

In 01 to 05, 07 and 08, a single line in sizeFor sets every word's size: 50 × (count ÷ top count)^0.5, with a floor of 10 px (07 then scales the result by 0.62). 06 and 09 define it without calling it, sizing with their own square-root formulas instead. The area a word covers grows with the square of its font size, so an exponent of 0.5 makes area proportional to count. 01's 16 words run from 50 px for 12 mentions through 35.4 px for 6 and 25 px for 3 down to 14.4 px for 1, yet size squared divided by count comes out at 208.3 for all 16. With an exponent of 1, height would follow the count while area followed its square, and the leader would look much further ahead than the votes say.

Thirty-Two Pixels in One Integer

Overlap is tested against letter shapes rather than boxes. makeSprite draws a word on a small canvas, reads it back with getImageData, sets a 1 bit for every pixel with alpha above 40, and marks the 3×3 neighborhood too, which leaves a 1 px gap between words. Thirty-two pixels of a row fit in one 32-bit integer, so the 480 px board is 17 integers per row (15, plus 2 of slack) by 300 rows. To try a word at x, each of its integers is shifted right by ox & 31, the remainder after dividing by 32, the bits that spill out are carried into the next integer, and the result is ANDed with the board; anything nonzero means at least one pixel overlaps.

        bitTests += 1;
        const s = ox & 31;
        const base = ox >> 5;
        const inv = 32 - s;
        for (let r = 0; r < sp.h; r += 1) {
            const row = (oy + r) * BOARD_INTS + base;
            const sr = r * sp.mw;
            let carry = 0;
            for (let k = 0; k < sp.mw; k += 1) {
                const v = sp.bits[sr + k];
                const part = s === 0 ? v : ((v >>> s) | carry);
                if ((board[row + k] & part) !== 0) {
                    return true;
                }
                carry = s === 0 ? 0 : (v << inv);
            }
            if (carry !== 0 && (board[row + sp.mw] & carry) !== 0) {
                return true;
            }
        }

One AND covers 32 pixels, so on paper a test needs a thirty-second of the comparisons a pixel-by-pixel check would make. That ratio is arithmetic about operation counts, not a timing. What the demo did measure is that 02 ran exactly one bit test per spiral step, 9,007 of each, so no step was thrown out for leaving the board before the test.

Where the Spiral Looks for Room

Candidate spots sit on a spiral running outward from the center. Step i turns i × 0.1 radians and lies i × 0.35 px from the center, stretched 1.6× sideways to suit the wide board, so each turn of about 62.8 steps adds 21.99 px of radius. The walk ends at step 809, half the board diagonal divided by 0.35, and a word that finds nothing by then is shrunk and tried again, as the edge case further down shows.

Word Cloud JavaScript: 9 Scenes — Count-Based Sizing, Spiral Placement, Split-Cell Collisions, Heart Fill, Topic Clusters, Word Bubbles, a 3D Sphere, Wind Sway and a Live Poll

All nine boards are drawn with canvas 2D and plain JavaScript: no library, no WebGL, no CDN and no image files. 06, 07, 08 and 09 need randomness and get it from the seeded makeRng, and everything else is arithmetic, so every run draws the same scene. Time advances in fixed 16 ms steps driven by requestAnimationFrame, up to six steps per frame with frame gaps capped at 100 ms, and there are no CSS keyframes and no timers.

Step and pixel counts in this post were taken with the Korean word set, which the measurements were run on. English words are wider, so the English version of 02 needs 10,308 spiral steps instead of 9,007 and 01 paints 35,109 pixels instead of 20,224, while sizes and vote tallies come from the counts alone and match across languages.

Twenty-Three Frame Comparisons in Two Seconds

Each board was captured as 24 frames over 2 seconds, and neighboring frames were compared 23 times. Accumulated area is the share of the board where a pixel changed at least once across those comparisons.

Board Accumulated area Frames moved Mean intensity
01 Counting board 15.3% 18/23 84.3
02 Spiral placement 18.5% 23/23 73.2
03 Split-cell collisions 63.0% 23/23 46.2
04 Heart fill 20.3% 22/23 61.1
05 Topic clusters 18.2% 23/23 71.3
06 Word bubbles 44.1% 23/23 92.2
07 3D sphere 31.1% 23/23 69.5
08 Wind sway 29.7% 23/23 33.4
09 Live poll 10.1% 22/23 76.7

03 covers the most area, 63.0%, most likely because it redraws the cell borders whenever the word count changes and tints the cells a new word touches in sky blue. 09 covers the least, 10.1%, which appears to come from only the voted word and the neighbors it bumps moving at all. The 18/23 for 01 and 22/23 for 04 and 09 look like frames caught while the screen held still, after a board filled up or between two votes.

01A Word Board Sized by Counting the Text

Thirty sample answers are cut at spaces, commas, periods, question marks and similar marks into 75 words that collapse to 16 unique ones, and these pop in from the most frequent at 105 ms intervals, each with a tag such as "12 mentions" floating above it. Case is folded, so a capitalized word at the start of an answer counts with its lowercase twin, which matters when raw survey text arrives exactly as people typed it. The code is the body of countWords, and on a tie the word that appeared first stays ahead.

A Word Board Sized by Counting the Text
Open in a new tab

01 · A Word Board Sized by Counting the Text — Measured area 15.29% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

SIZE_EXPONENTSPLIT_PATTERNAPPEAR_GAP_MS
        const counts = new Map();
        const order = [];
        let tokens = 0;
        for (let i = 0; i < lines.length; i += 1) {
            const parts = lines[i].split(SPLIT_PATTERN);
            for (let k = 0; k < parts.length; k += 1) {
                const w = parts[k].trim().toLowerCase();
                if (!w) {
                    continue;
                }
                tokens += 1;
                const c = counts.get(w);
                if (c === undefined) {
                    order.push(w);
                    counts.set(w, 1);
                }
                else {
                    counts.set(w, c + 1);
                }
            }
        }
        const entries = order.map(function (w, i) { return { text: w, count: counts.get(w) || 0, theme: i }; });
        entries.sort(function (a, b) { return b.count - a.count || a.theme - b.theme; });
        return { entries: entries, tokens: tokens };

02Walking a Spiral to Find Empty Space

The first 36 words leave from (240, 146) and step along the spiral, testing the word's bit grid against the board at every step and dropping into the first free spot. A faint trail of the walk and a dashed pink box around the spot being tested make this a tag cloud JavaScript demo that shows its own reasoning, which suits a blog tag overview page that explains how its layout was built. The 36 words took 9,007 steps, 250.2 per word on average and 325 at most, and the caption in the lower left reads "This word: N spiral steps" for the word in flight.

Walking a Spiral to Find Empty Space
Open in a new tab

02 · Walking a Spiral to Find Empty Space — Measured area 18.528% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPIRAL_STEPSPIRAL_TURNBOARD_INTS
    function spiralAt(i) {
        const a = i * SPIRAL_TURN;
        const r = i * SPIRAL_STEP;
        return [Math.cos(a) * r * SPIRAL_ASPECT, Math.sin(a) * r];
    }
    // Spiral search: step along the spiral from the anchor (cx, cy), center the glyph box on each point, and return the first spot where the bit grids do not overlap.
    // Pass a trail to record every spiral point walked (02 draws that trail)
    function searchSpiral(board, sp, cx, cy, trail) {
        for (let i = 0; i < SPIRAL_LIMIT; i += 1) {
            spiralSteps += 1;
            const p = spiralAt(i);
            const ox = Math.round(cx + p[0] - sp.w / 2);
            const oy = Math.round(cy + p[1] - sp.h / 2);
            if (trail) {
                trail.push([cx + p[0], cy + p[1]]);
            }
            if (!collides(board, sp, ox, oy)) {
                return [ox, oy];
            }
        }
        return null;
    }

03Splitting Cells in Four for Faster Collision Checks

Each word box is filed into the cells it touches, and a cell holding more than 4 boxes splits into four, down to depth 6, so a new box is compared only with the boxes sharing its cells. The 48 main words plus refills at 11 to 8 px reach 153 before the cap of 160 is used up, leaving 187 leaf cells with the deepest at depth 5, and the strip along the bottom reports 140,332 cell checks against 2,301,880 for comparing one by one, 16.4 times fewer. Boxes here come from measureText rather than bit grids, and the marks stamp in the code stops a box that spans several cells from being counted twice, a fit for a comment keyword dashboard with many short terms.

Splitting Cells in Four for Faster Collision Checks
Open in a new tab

03 · Splitting Cells in Four for Faster Collision Checks — Measured area 63.024% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

QUAD_CAPACITYQUAD_MAX_DEPTHFILL_SIZES
    function quadHits(n, b, all) {
        if (!boxesTouch(n, b)) {
            return false;
        }
        if (n.kids) {
            for (let k = 0; k < 4; k += 1) {
                if (quadHits(n.kids[k], b, all)) {
                    return true;
                }
            }
            return false;
        }
        for (let i = 0; i < n.ids.length; i += 1) {
            const id = n.ids[i];
            if (marks[id] === markStamp) {
                continue;
            }
            marks[id] = markStamp;
            treeChecks += 1;
            if (boxesTouch(all[id], b)) {
                return true;
            }
        }
        return false;
    }

04Filling a Heart Shape by Depth

The board is tiled into 4 px cells, 120 × 75 = 9,000 of them, and the 3,516 that fall inside the curve (u² + v² − 1)³ − u²v³ ≤ 0 get their distance to the edge from two sweeps that add 3 for a straight step and 4 for a diagonal one. Pixels outside the heart are painted into the board in advance, so the ordinary overlap test alone keeps words inside, and words are seated from the deepest cell at 106.7 px outward: all 48 fit, sizes fall from 45 px to 9.2 px where the later words had to shrink, and 18 refill words bring the total to 66, filling the shape well before the cap of 170. Colors follow depth from pink through peach, lemon and mint to sky, which reads like an anniversary or thank-you poster.

Filling a Heart Shape by Depth
Open in a new tab

04 · Filling a Heart Shape by Depth — Measured area 20.323% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

HEART_SCALEDEPTH_CELLSHRINK_RATIO
        // Anything off the board counts as outside (0)
        const at = function (i, j) { return i < 0 || j < 0 || i >= GW || j >= GH ? 0 : d[j * GW + i]; };
        for (let j = 0; j < GH; j += 1) {
            for (let i = 0; i < GW; i += 1) {
                const c = j * GW + i;
                if (d[c] === 0) {
                    continue;
                }
                d[c] = Math.min(d[c], at(i - 1, j) + 3, at(i, j - 1) + 3, at(i - 1, j - 1) + 4, at(i + 1, j - 1) + 4);
            }
        }
        for (let j = GH - 1; j >= 0; j -= 1) {
            for (let i = GW - 1; i >= 0; i -= 1) {
                const c = j * GW + i;
                if (d[c] === 0) {
                    continue;
                }
                d[c] = Math.min(d[c], at(i + 1, j) + 3, at(i, j + 1) + 3, at(i + 1, j + 1) + 4, at(i - 1, j + 1) + 4);
            }
        }
        for (let c = 0; c < d.length; c += 1) {
            d[c] = d[c] * DEPTH_CELL / 3;
        }

05Color Clouds Grouped by Topic

Every word belongs to one of four topics, Pace, Together, How we work and Growing, and searches a spiral that starts at its topic's anchor, (134, 100), (346, 96), (136, 208) or (348, 206), all on one shared board. The 48 words land 12, 12, 13 and 11 per topic without a single shrink, and each topic's first word sits within 1 px of its anchor. As each word lands, a thread back to its anchor shows for 450 ms and the anchor ring swells and eases back, a clear way to sort feedback from a team retrospective into groups you can see.

Color Clouds Grouped by Topic
Open in a new tab

05 · Color Clouds Grouped by Topic — Measured area 18.234% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

THEME_ANCHORSTHEME_COLORSLINK_MS
        for (let i = 0; i < THEME_LABELS.length; i += 1) {
            const b = labelBox(i);
            labels.push(b);
            blockRect(board, b[0] - 2, b[1] - 2, b[2] + 4, b[3] + 4);
        }
        placed = [];
        for (let i = 0; i < VOCAB.length; i += 1) {
            const e = VOCAB[i];
            const a = THEME_ANCHORS[e.theme];
            const p = placeEntry(board, e, sizeFor(e.count, top) * SIZE_SCALE, a[0], a[1], THEME_COLORS[e.theme], null);
            if (p) {
                placed.push(p);
            }
        }

06Word Bubbles That Push Into Place

Twenty-two bubbles get a radius of 50 × √(count ÷ 24) with a floor of 14, so area tracks count (50, 44.5, 40.8 and on down to 20.4 px), and they inflate one by one at 60 ms intervals. Each 16 ms tick pulls every bubble toward the center, 1.8 times harder vertically, and then three relaxation passes push apart any pair closer than their radii plus 3 px, splitting the move by area so the smaller bubble gives way more. The summed overlap peaks at 143.6 px at 1,504 ms while bubbles are still growing and sinks to about 25 px, a packed cluster for a trending keyword ranking board, and since growth only ever raises a radius toward its maximum, the inflating plays in the first loop only and later loops show the outward kick at 3,000 ms and the regroup.

Word Bubbles That Push Into Place
Open in a new tab

06 · Word Bubbles That Push Into Place — Measured area 44.077% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PULLPUSHRELAX_PASSES
        for (let i = 0; i < bubbles.length; i += 1) {
            const b = bubbles[i];
            b.vx = (b.vx + (W / 2 - b.x) * PULL) * DAMPING;
            b.vy = (b.vy + (H / 2 - b.y) * PULL * PULL_Y) * DAMPING;
            b.x += b.vx;
            b.y += b.vy;
            b.press = 0;
        }
                    const dx = b.x - a.x;
                    const dy = b.y - a.y;
                    const d = Math.hypot(dx, dy) || 0.01;
                    const gap = ra + rb + BUBBLE_GAP - d;
                    if (gap <= 0) {
                        continue;
                    }
                    if (pass === 0) {
                        overlapSum += gap;
                        a.press += gap;
                        b.press += gap;
                    }
                    const nx = dx / d;
                    const ny = dy / d;
                    // The smaller bubble moves more: my share = the other's area / the sum of both areas
                    const share = (rb * rb) / (ra * ra + rb * rb);
                    a.x -= nx * gap * PUSH * share;
                    a.y -= ny * gap * PUSH * share;
                    b.x += nx * gap * PUSH * (1 - share);
                    b.y += ny * gap * PUSH * (1 - share);

07A 3D Sphere of Words That Spins

This 3D word cloud JavaScript needs no 3D library: 42 words sit on points whose height steps evenly from top to bottom while each step turns a further 137.51°, the golden angle, and on a unit sphere the nearest neighbors end up between 0.477 and 0.525 apart. project rotates each point and scales it by 3.2 ÷ (3.2 + z), 1.455 at the front and 0.762 at the back, and words are drawn back to front, nearer ones more opaque and farther ones blended up to 55% into the board color. The sphere turns at 31.5° per second and leans up to 34.4° toward an 8-point virtual pointer loop until a real pointer takes over the lean, and front words show a tooltip, which suits the skills section of a portfolio.

A 3D Sphere of Words That Spins
Open in a new tab

07 · A 3D Sphere of Words That Spins — Measured area 31.059% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GOLDEN_ANGLECAMERA_DISTPOINTER_SCHEDULE
    function spherePoint(i, n) {
        const y = 1 - (i + 0.5) * 2 / n;
        const r = Math.sqrt(Math.max(0, 1 - y * y));
        const phi = i * GOLDEN_ANGLE;
        return [Math.cos(phi) * r, y, Math.sin(phi) * r];
    }
    function project(ux, uy, uz) {
        const a = yaw + tiltY;
        const x1 = ux * Math.cos(a) - uz * Math.sin(a);
        const z1 = ux * Math.sin(a) + uz * Math.cos(a);
        const y2 = uy * Math.cos(tiltX) - z1 * Math.sin(tiltX);
        const z2 = uy * Math.sin(tiltX) + z1 * Math.cos(tiltX);
        const k = CAMERA_DIST / (CAMERA_DIST + z2);
        return [W / 2 + x1 * SPHERE_R * k, H / 2 - y2 * SPHERE_R * k, z2, k];
    }

08A Word Background Swaying in the Wind

noise2 gives each whole-number grid point a value from a seeded shuffle table and blends between neighbors with a cubic ease, and each of the 40 placed words reads two such values that drift over time to set its tilt and its lift. A gust, 0.3 at rest plus 0.7 under a bell 120 px wide, crosses from left to right at 190 px per second, one pass taking 3,789 ms, and words under it brighten by up to 35% toward cream. Across 4 seconds the largest tilt was 8.6° and the largest lift 5.7 px, gentle enough to sit behind the headline of a landing page hero.

A Word Background Swaying in the Wind
Open in a new tab

08 · A Word Background Swaying in the Wind — Measured area 29.658% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

NOISE_SCALESWAY_MAXGUST_SPEED
    function noise2(x, y) {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = x - ix;
        const fy = y - iy;
        const u = fx * fx * (3 - 2 * fx);
        const v = fy * fy * (3 - 2 * fy);
        return lerp(lerp(lattice(ix, iy), lattice(ix + 1, iy), u), lerp(lattice(ix, iy + 1), lattice(ix + 1, iy + 1), u), v);
    }
            const gust = gustAt(cx, ts);
            const g = gust * calm;
            const n1 = noise2(cx * NOISE_SCALE - ts * WIND_SPEED, cy * NOISE_SCALE);
            const n2 = noise2(cx * NOISE_SCALE + 17.3, cy * NOISE_SCALE - ts * WIND_SPEED * 0.8);
            // Words under the gust brighten slightly toward cream
            const glow = calm * (gust - GUST_BASE) / (1 - GUST_BASE);
            drawPlaced(p, 1, 1, n1 * SWAY_MAX * g, (n2 * 0.5 + 0.5) * LIFT_MAX * g, mixRGB(p.color, CREAM, 0.35 * clamp(glow, 0, 1)));

09A Live Word Board That Grows With Each Vote

Fourteen options receive 56 scheduled votes, one every 125 ms from 300 ms on, and each word closes 12% of the gap to its new size on every tick. When two target boxes overlap, only the side with fewer votes, the later one on a tie, runs relocate, which walks up to 700 spiral steps out from its current target to the first free spot, and the word follows on a spring with stiffness 0.18 and damping 0.72. Over one loop of the schedule the lead changed four times, to communication at 304 ms, deadline at 2,304, collaboration at 2,560 and deadline again at 3,312 ms, yet all 54 moves were neighbors stepping aside and no step rebuilt the whole board, which is what a live poll screen for a talk or class needs.

A Live Word Board That Grows With Each Vote
Open in a new tab

09 · A Live Word Board That Grows With Each Vote — Measured area 10.128% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

REFLOW_STIFFNESSSIZE_LERPVOTE_GAP_MS
    function relocate(i) {
        const o = options[i];
        for (let s = 1; s < SEARCH_LIMIT; s += 1) {
            const p = spiralAt(s);
            const r = rectOf(o, o.tx + p[0], o.ty + p[1], o.goal);
            if (fits(i, r)) {
                o.tx += p[0];
                o.ty += p[1];
                moves += 1;
                return true;
            }
        }
        return false;
    }
        for (let i = 0; i < options.length; i += 1) {
            for (let j = i + 1; j < options.length; j += 1) {
                const a = options[i];
                const b = options[j];
                if (!touch(rectOf(a, a.tx, a.ty, a.goal), rectOf(b, b.tx, b.ty, b.goal))) {
                    continue;
                }
                relocate(b.votes > a.votes ? i : j);
            }
        }
        for (let i = 0; i < options.length; i += 1) {
            const o = options[i];
            o.vx = (o.vx + (o.tx - o.x) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
            o.vy = (o.vy + (o.ty - o.y) * REFLOW_STIFFNESS) * REFLOW_DAMPING;
            o.x += o.vx;
            o.y += o.vy;
        }

Three Ways a Word Cloud Layout Breaks: Long Words at the Edge, Fonts That Load Late, Hundreds of Words

A Long Word Hanging Off the Edge

Because the spiral is stretched sideways and runs out to half the diagonal, a good share of its candidate spots cross the board edge. That is why collides treats a box that leaves the board by even 1 px as an overlap; without that check, a long word would be placed with its end cut off. When no spot turns up at all, placeEntry shrinks the word to 0.85× and searches again from the start, and it drops the word once the size falls below 8 px.

    function placeEntry(board, e, size, cx, cy, color, trail) {
        let s = size;
        let shrinks = 0;
        while (s >= SIZE_FLOOR) {
            const sp = makeSprite(e.text, s);
            if (trail) {
                trail.length = 0;
            }
            const at = searchSpiral(board, sp, cx, cy, trail);
            if (at) {
                stamp(board, sp, at[0], at[1]);
                return { text: e.text, count: e.count, theme: e.theme, size: s, x: at[0], y: at[1], w: sp.w, h: sp.h, dx: sp.dx, dy: sp.dy, color: color, shrinks: shrinks };
            }
            s *= SHRINK_RATIO;
            shrinks += 1;
            shrinkCount += 1;
        }
        dropCount += 1;
        return null;
    }

On an empty board, a three-word Korean phrase meaning "communication feedback meetings" set at 90 px needed five shrinks before it fit, ending at 39.9 px and 461 px wide, while "collaboration" at 90 px needed one, landing at 76.5 px and 478 px wide. At 50 px the same "collaboration" fit unchanged, 314 px wide. In the default layouts 01, 02, 05 and 08 shrank zero words in all three languages; only 04 shrinks, because the heart runs short of depth and its later big words drop to 9.2 px.

A Font That Arrives Late

A bit grid is a snapshot of a word drawn once, so if the font changes after the grid is built, the letters on screen and the grid no longer match. Measured at 50 px in the demo font and in a fallback serif, the Korean word for collaboration came out 1 px narrower and the Korean word for feedback 1 px wider, but "collaboration" grew by 10 px (314 against 324 px) and the Japanese katakana word for feedback by 11 px (332 against 343 px). So every board waits for document.fonts.ready before it lays anything out, and if a loadingdone event reports a new font afterward, it measures the widths again and rebuilds the layout. The demo font is a list of fonts installed on the device, so nothing is downloaded, though a web font added on top would likely make this ordering matter more.

Hundreds of Words

Checking a new word against every word already placed costs more with each word on the board. In 03, 153 words took 140,332 cell checks against 2,301,880 one-by-one comparisons, 16.4 times fewer, and shrinking the sizes until 238 words fit pushed that to 473,909 against 17,821,535, or 37.6 times. About 1.6 times as many words more than doubled the gap, so a board meant for hundreds of words needs something that narrows the comparison set first, either the cell split in 03 or the bit grid in 02, which tests one word against the board as a whole.

The nine boards with all three of these guards, plus React versions generated from the same source, come in one zip that opens with the password p49vy6me when you extract it.

A Tighter Spiral, Bigger Size Gaps, a Softer Spring — Three One-Value Variations

Like the step counts above, these figures come from the Korean word set.

Name Changed value What changes
Tighten the spiral 02 SPIRAL_STEP 0.35 → 0.15 Each turn widens by 9.42 px instead of 21.99 px, steps climb from 9,007 to 19,547 (543 per word on average), and the same letters pack into 360 × 211 px instead of 383 × 221 px, raising ink inside the bounds from 38.9% to 43.3%
Widen the size gap 01 SIZE_EXPONENT 0.5 → 0.8 The top word's share of all word-box area grows from 13.5% to 19.0%, words said once fall from 14.4 px to the 10 px floor, and painted pixels drop from 20,224 to 14,534; all 16 words still fit
Soften the spring 09 REFLOW_STIFFNESS 0.18 → 0.08 With the target moved 40 px, first entry into the ±0.5 px band slows from 224 ms to 384 ms, but overshoot shrinks from 9.52 px to 3.15 px and the word stays in once it arrives, so it is settled for good at 384 ms instead of 432 ms

Each variation gives something up. The tighter spiral stretches the 02 demo loop from 12.0 to 17.8 seconds. The 0.8 exponent breaks area-for-count, spreading size squared over count from 208.3 for the top word to 71.1 for the last, so the leader looks bigger than its lead. The softer spring reacts later but rocks less, and for a poll where votes arrive in bursts, 0.08 would likely be easier to read.

Accessibility for a Cloud of Words: A Ranked List Screen Readers Can Follow and a Still Layout Under Reduced Motion

A Hidden Ranked List Beside Each Canvas

Letters painted onto a canvas are invisible to a screen reader, so every board keeps a visually hidden <ol> labeled "Words ranked by frequency", with entries in rank order in the form "word — 12 mentions". It is hidden with a 1 px box and clip-path: inset(50%) rather than display: none, so assistive tech still reaches it, and an <li> is rewritten only when a rank or count changes. The lists hold 16 entries in 01, 36 in 02, 48 in 03 to 05, 22 in 06, 42 in 07, 40 in 08 and 14 in 09. The canvas carries role="img" and an aria-label that pairs the scene description with the top three after "Most frequent:" ("Current leaders:" in 09), and the label is updated whenever the ranking moves.

The One Frame Drawn Under Reduced Motion

With prefers-reduced-motion set to reduce, none of the nine boards starts its frame loop; finalScene() runs the layout to the end and draws that single frame. 01 to 05 show every word in place, 06 a bubble cluster settled through 600 undrawn ticks, 07 the sphere at 0.7 radians with no tilt, 08 a board with the wind gone still, and 09 the final tally after all 56 votes: deadline 16, collaboration 12, communication 12, feedback 8. A change listener applies the setting even if it flips while the page is open. Tooltips still appear under the pointer in this mode, front words on 07 included, but 07 stops leaning toward the pointer since its loop is not running, and 09's button still adds a vote and redraws at once.

09 Speaks Only When the Lead Changes

During the automatic demo the announcement line in 09 stays at aria-live="off", so a vote every 125 ms is not read aloud, and the first pointer move or button press switches it to polite. Even then it rewrites its text only when the lead changes hands, as in "New leader: deadline". The "Add one vote" button is a real <button> that Tab reaches and Enter presses, with a 2 px focus outline.

Questions People Ask Before Adding a Word Cloud to a Report or Live Poll

Can a word cloud animation be saved as a still image for a report?

Not as shipped: the demos have no save path, so this is something you would add. The natural route is to draw the finished layout the way finalScene() does under reduced motion and then export the canvas as an image, keeping in mind that the canvas holds its display size times the device pixel ratio, capped at 2. Where exact numbers matter, the hidden ranked list can sit beside the picture as a table.

Why does one word look bigger than another with the same count?

The size sets the font size, so a longer word gets a wider box at the same size. At 50 px "collaboration" is 314 px wide, and with identical counts 01 paints 35,109 pixels on the English board against 20,224 on the Korean one. Matching area to count exactly would mean solving for the font size from each word's measured box, whereas this demo keys everything to font size.

How many words can a spiral and a bit grid handle on their own?

A few dozen words is easy: 02 places its 36 in 9,007 spiral steps on the Korean board and 10,308 on the English one. Going from 153 to 238 words in 03 lifted one-by-one comparisons from about 2.3 million to 17.8 million, so once a board passes a hundred words or so, the cell split is worth adding. For points and lines that pull and push into place, continue with force-directed network graphs, and for the same dashboard family drawn as bars, see animated bar charts.

Enter the archive password

The password is inside this article. You will find it as you read.