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

Bubble Chart: 9 Canvas Scenes

How does a bubble chart turn one row of data into a circle? 01 uses the square root of the value as radius, 02 pushes overlapping circles apart, 09 zooms into

A Bubble Chart Is Not Dots on a Plot but a Calculation That Folds Horizontal Position, Vertical Position and Area Into One Circle: Making a Bubble Chart Starts by Deciding Which Value Goes to Position and Which Goes to Size

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

Every scene here builds on one shared object. A bubble is an object with five fields, { x, y, r, v, g }, the overlap function changes x and y, the radius function sets r, and the screen only reads that object list and the scene clock to paint. The canvas does not decide a position on its own.

Where the three values go is the first decision of the chart

The horizontal and vertical values pass through ticks and become coordinates, and the size value becomes a radius. A size value has no axis, so scene 03 draws a legend of three sample circles instead. A bubble chart diagram that skips this decision ends up with circles whose sizes mean nothing.

The clock and the random source are fixed inside each scene

A scene calls neither Date nor Math.random. Progress comes from the elapsed time on the scene timetable, and scattered starting spots come from a seeded generator, so one scene builds the same bubble layout each run. The names and values are made up.

Bubble Chart: 9 Scenes — Radius Scaled to Area, Overlap Relaxed by Repeated Pushes, Ticks for Three Values, Bubbles Pushed Away From the Pointer, a Spring Move When a Value Changes, Labels Fitted by Measure, a Motion Trail Between Time Steps, Group Gravity and a Zoom Into a Group

The order runs from size to neighbors, then time, then groups. 01 to 03 cover radius, placement and ticks, 04 and 05 cover the pointer and value updates, 06 covers labels, 07 covers movement between time steps, and 08 and 09 cover groups and layers.

What nine renders measured for area, frames and intensity

Scene Cumulative area Moving frames Mean intensity
01 Area radius 11.0% 23/23 44.3
02 Overlap relaxation 1.7% 20/23 50.6
03 Three-value ticks 29.9% 19/23 50.0
04 Pointer repel 17.1% 23/23 50.7
05 Spring reflow 9.0% 23/23 38.4
06 Label fit 14.0% 19/23 41.7
07 Motion trail 14.9% 23/23 58.6
08 Group gravity 37.2% 23/23 28.5
09 Zoom into a group 43.4% 19/23 35.5

Scene 09 covers the most area at 43.4% and 02 the least at 1.7%, while mean intensity peaks at 58.6 in 07 and bottoms out at 28.5 in 08. Scenes 01, 04, 05, 07 and 08 moved in 23/23 frames, and 03, 06 and 09 in 19/23.

01Radius Scaled to Area

A reader judges size by area, so the area has to follow the value and the radius cannot. radiusOf takes radius 18 at value 20, scales with the square root of the value and clamps between R_MIN 6 and R_MAX 46, while radiusLinear on the right is a deliberate contrast that scales the radius straight to the value. The scene covers 11.0% cumulative area and moved in 23/23 frames.

Radius Scaled to Area
Open in a new tab

01 · Radius Scaled to Area — Measured area 11.014% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

R_MINR_MAXradiusOf
    // Radius 18 starts from value 20, so both boards split from the same reference bubble
    const R_REF = 18;
    const V_REF = 20;
    const VALS = [36, 30, 25, 20, 16, 13, 11, 9];
    const GROW_I = 3;
    const LAP_S = 9.6;
    const START_S = 1.6;
    const REST_S = 5.2;
    // Area basis: the radius follows the square root of the value
    function radiusOf(v) { return clamp(R_REF * Math.sqrt(v / V_REF), R_MIN, R_MAX); }
    // Contrast board: the radius follows the value directly
    function radiusLinear(v) { return clamp(R_REF * v / V_REF, R_MIN, R_MAX); }

02Overlap Relaxed by Repeated Pushes

Bubbles stay readable when overlap is pushed away PACK_ITER times per round, even if the data points coincide. When two centers sit closer than the sum of their radii, the overlap is split by the opposite radius ratio, so the smaller circle moves more, and the line on the right plots the leftover overlap sum after each round. The scene covers 1.7% cumulative area, the smallest, and moved in 20/23 frames.

Overlap Relaxed by Repeated Pushes
Open in a new tab

02 · Overlap Relaxed by Repeated Pushes — Measured area 1.684% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

PACK_ITERoverlapOfrelax
    function doRound() {
        for (let p = 0; p < PACK_ITER; p += 1) {
            relaxPass(bs, 0);
            keepIn(bs, PANEL_L);
        }
        relaxPass(bsSlow, 0);
        keepIn(bsSlow, PANEL_L);
        hist.push(overlapOf(bs));
        histSlow.push(overlapOf(bsSlow));
        round += 1;
    }

03Ticks for the Three Values of X, Y and Size

Three variables on one sheet are hard to read when an axis ends at an awkward number. niceStep divides the range by TICK_TARGET 5 and picks a step of 1, 2 or 5 times a power of ten, and the size legend draws three circles from the largest such number not above the maximum, plus its half and its fifth. The scene covers 29.9% cumulative area and moved in 19/23 frames.

Ticks for the Three Values of X, Y and Size
Open in a new tab

03 · Ticks for the Three Values of X, Y and Size — Measured area 29.922% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

niceStepTICK_TARGETlegendSizes
    function niceStep(range, target) {
        const raw = range / target;
        const p = Math.pow(10, Math.floor(Math.log10(raw)));
        const f = raw / p;
        const m = f <= 1.5 ? 1 : (f <= 3.5 ? 2 : (f <= 7.5 ? 5 : 10));
        return m * p;
    }
    function niceAxis(lo, hi) {
        const step = niceStep(hi - lo, TICK_TARGET);
        return { a: Math.floor(lo / step + 1e-9) * step, b: Math.ceil(hi / step - 1e-9) * step, step: step };
    }
    // Size legend: the largest 1, 2 or 5 times a power of ten not above the max goes on top, then its half and its fifth
    function floorNice(v) {
        const p = Math.pow(10, Math.floor(Math.log10(v)));
        const f = v / p;
        return (f >= 5 ? 5 : (f >= 2 ? 2 : 1)) * p;
    }
    function legendSizes(vmax) {
        const top = floorNice(vmax);
        return [top, top / 2, top / 5];
    }

04Bubbles Pushed Away From the Pointer

A hover that lets you read one bubble needs its neighbors to step aside, and this scene shows it with a fake pointer moved by the timetable. falloff reaches 1 at distance 0 and 0 at REPEL_R 64, so no bubble pops at the edge, and pushed targets are followed with FOLLOW and settle back with DAMP 0.78. The scene covers 17.1% cumulative area and moved in 23/23 frames.

Bubbles Pushed Away From the Pointer
Open in a new tab

04 · Bubbles Pushed Away From the Pointer — Measured area 17.131% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

REPEL_RfalloffDAMP
    // Force strength: 1 at distance 0, 0 at the REPEL_R edge, a smooth curve between (slope 0 at the edge too)
    function falloff(d) {
        if (d >= REPEL_R) {
            return 0;
        }
        const u = 1 - d / REPEL_R;
        return u * u * (3 - 2 * u);
    }

05Springing to a New Size When the Value Changes

When a live number updates, a radius that jumps straight to the target makes the screen flicker. The left board multiplies the gap to the target radius by SPRING_K 0.08, adds it to the velocity and multiplies by SPRING_DAMP 0.8, so it overshoots once and settles, where the target is R_K 4.2 times the square root of the value and a grown bubble pushes overlap once per step. Across four value changes the scene covers 9.0% cumulative area and moved in 23/23 frames.

Springing to a New Size When the Value Changes
Open in a new tab

05 · Springing to a New Size When the Value Changes — Measured area 9.016% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KtargetR_K
    // One fixed step: the left board adds a force (target gap times SPRING_K) to the velocity, multiplies by SPRING_DAMP, then adds it to the radius
    function step() {
        for (let i = 0; i < N; i += 1) {
            velR[i] = (velR[i] + (target(i) - ballsL[i].r) * SPRING_K) * SPRING_DAMP;
            ballsL[i].r += velR[i];
            ballsR[i].r = target(i);
        }
        relax(ballsL, PANEL_L, 1, 1.5, 0.012);
        relax(ballsR, PANEL_R, 1, 1.5, 0.012);
    }

06Measuring Whether a Label Fits Inside

A small bubble still needs a readable name, so the text width is compared with the inner width before a spot is chosen. chordW is the chord length at half the text height off center minus LABEL_PAD 3 on both sides, and planOf tries a big font, a small font, the first few characters and finally a label outside the circle, in that order. The scene covers 14.0% cumulative area and moved in 19/23 frames.

Measuring Whether a Label Fits Inside
Open in a new tab

06 · Measuring Whether a Label Fits Inside — Measured area 13.956% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

LABEL_PADglyphWchordW
    // Inner width of the bubble: the chord length at half the text height off center, minus the side padding
    function chordW(r, px) { return 2 * Math.sqrt(Math.max(0, r * r - (px * 0.55) * (px * 0.55))) - 2 * LABEL_PAD; }
    function planOf(name, r) {
        for (const px of [FONT_BIG, FONT_SMALL]) {
            if (glyphW(name, px) <= chordW(r, px)) {
                return { mode: 0, px: px, s: name };
            }
        }
        // First few characters only: the longest prefix that fits (2 or more)
        for (let n = name.length - 1; n >= 2; n -= 1) {
            const s = name.slice(0, n);
            if (glyphW(s, FONT_SMALL) <= chordW(r, FONT_SMALL)) {
                return { mode: 1, px: FONT_SMALL, s: s };
            }
        }
        return { mode: 2, px: FONT_SMALL, s: name };
    }

07A Motion Trail Joining Time Steps

The three values given per year must be joined by a curve for a bubble to glide between time steps. splineStep joins two steps with a cubic curve through four points, yearAt eases the step position once from 0.5 to 7.6 seconds, and the places the bubble has been stay as TRAIL_LEN 16 dots. The scene covers 14.9% cumulative area and moved in 23/23 frames.

A Motion Trail Joining Time Steps
Open in a new tab

07 · A Motion Trail Joining Time Steps — Measured area 14.902% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRAIL_LENsplineStepyearAt
    // A cubic curve joining two time steps through four points (before, start, end, after); pos is a fractional position from 0 to KEYS - 1
    function splineStep(a, pos) {
        const i = Math.min(Math.floor(pos), a.length - 2);
        const u = pos - i;
        const p0 = a[Math.max(i - 1, 0)];
        const p1 = a[i];
        const p2 = a[i + 1];
        const p3 = a[Math.min(i + 2, a.length - 1)];
        return 0.5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u);
    }

08Gravity Pulling to Each Group Center

To show items gathered by category, each bubble needs a pull toward the center of its group. step adds the vector to the center times PULL 0.02 to the velocity, multiplies by DAMP 0.82, moves, then pushes overlaps once to balance, and the gap between groups opens by changing one set of centers with setCenters. The scene covers 37.2% cumulative area and moved in 23/23 frames.

Gravity Pulling to Each Group Center
Open in a new tab

08 · Gravity Pulling to Each Group Center — Measured area 37.159% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PULLcentersDAMP
    // One fixed step: add (vector to the center times PULL) to the velocity, multiply by DAMP and move, then push overlaps once to balance
    function step() {
        for (let i = 0; i < bs.length; i += 1) {
            const c = centers[bs[i].g];
            vel[i][0] = (vel[i][0] + (c[0] - bs[i].x) * PULL) * DAMP;
            vel[i][1] = (vel[i][1] + (c[1] - bs[i].y) * PULL) * DAMP;
            bs[i].x += vel[i][0];
            bs[i].y += vel[i][1];
        }
        relaxPass(bs, 1);
        keepIn(bs, PANEL_ONE);
    }

09Zooming Into a Circle Packed Inside a Circle

Moving from a broad class down to detail takes small circles packed in an outer circle with the same relaxation and an outer radius computed separately. encloseR shifts the inner center 80 times toward the circle sticking out furthest, so its result is an approximate enclosing radius rather than the exact smallest one, and picking a group sends the view box in with ZOOM_EASE 3.2 while sX turns coordinates into the zoom scale. The scene covers 43.4% cumulative area, the largest, and moved in 19/23 frames.

Zooming Into a Circle Packed Inside a Circle
Open in a new tab

09 · Zooming Into a Circle Packed Inside a Circle — Measured area 43.374% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

ZOOM_EASEencloseRsX
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        const k = 1 - Math.exp(-ZOOM_EASE * dt);
        const tx = zg < 0 ? CX0 : grps[zg].x;
        const ty = zg < 0 ? CY0 : grps[zg].y;
        const ts = zg < 0 ? 1 : 98 / (grps[zg].R + PAD_G);
        vcx += (tx - vcx) * k;
        vcy += (ty - vcy) * k;
        vs += (ts - vs) * k;
    }
    function sX(x) { return (x - vcx) * vs + CX0; }

When You Draw a Bubble Chart in Code, How Many Values Turn One Row of Data Into One Circle?

One bubble is five fields

One row of data takes two position values and one size value and becomes { x, y, r, v, g }. Every scene uses these five names, so the overlap function and the label function run in each other's scenes unchanged.

Field Meaning Used by
x, y Center coordinates of the circle relaxPass, keepIn
r Radius, derived from the square root of the size value relaxPass, chordW
v The size value itself, the source of radius and legend radiusOf, legendSizes
g Group number the bubble belongs to step, setCenters

Write the data as arrays and let ticks be computed

The data in 03 and 07 sits in three arrays, xs, ys and ss, and niceAxis computes the tick step and axis ends from the minimum and maximum. Swapping the data moves ticks and circles together, so no axis number is ever set by hand.

Move Two Values Onto Ticks, Turn the Size Value Into a Radius by Square Root and Push Apart Any Overlap: the Order in Which One Bubble Chart Circle Is Drawn

Ticks first, radius second

niceAxis fixes the ends of the horizontal and vertical axes so a value can become a coordinate, and radiusOf turns the size value into a radius. Only after both finish is the bubble list complete and the overlap relaxation starts.

Relaxation shrinks the overlap round by round

With PACK_ITER at 4 the leftover overlap sum reaches only 0.6 after the third round, and after twelve rounds it is 0. The dotted line in 02 pushes just once per round, so the two curves part on the right line.

Text goes on after the positions are fixed

planOf names a label only for a bubble whose position and radius are done. When a value changes the radius, the same order picks the label shape again.

Putting It on a Real Screen Like Bubble Chart Examples: Building Without a Library, Writing Data as an Array and When Values Get Refreshed

Where to attach it

Comparing items on a dashboard, showing portfolio weights or showing survey results by response count are all cases where circle size becomes the story. Anyone sketching a bubble chart maker of their own will need the relaxation in 02 and the label rule in 06 first.

An update changes one value

Scene 05 changes only vals[i] and the target radius function and the spring take over the rest. On a real screen too, a new number needs only that bubble's value changed, and the bubble list is not rebuilt.

How this differs from a treemap

The treemap chart article splits cell area into rectangles, while this one lets circles push each other to find a spot, so position itself is a result of the calculation.

Drawing it without a library

Canvas 2D and the functions in this article are all that drawing needs. The nine scenes load no external script and no WebGL and each runs inside a single file.

Where a Bubble Chart Breaks: a Trap of Radius Scaled Straight to the Value So Area Looks Squared, Circles That Stay Overlapped When Relaxation Stops Early and a Long Label Spilling Out of a Small Circle

Scaling the radius straight to the value

When a value doubles from 20 to 40, the square root board grows its radius from 18 to 25.5 and the area doubles, but the linear board grows it from 18 to 36 and the area reads as four times. The contrast board on the right of 01 shows that gap side by side with the same value.

When relaxation does not finish

With PACK_ITER left at 4, an overlap sum of 0.6 still remains after three rounds. In 08, raising PULL to 0.08 makes the pull toward the group and the push against overlap collide, so the overlap sum reaches 87.1, the mean speed reaches 6.81 and the cluster becomes unstable.

When a long label spills out of a small circle

With a fixed font size and no width measuring, the name spills over a neighbor circle. Scene 06 measures the inner width with chordW to pick a shape, and a circle that still cannot hold it gets a leader line and a label outside.

The source of the nine scenes is in the zip card below, and you unpack it with the password pwwb8ndg, typed exactly as shown.

Change the Pass Count, the Spring Strength and the Gravity Strength and How Much Does the Feel Shift? PACK_ITER, SPRING_K and PULL Compared

Name Changed value Feel
More relaxation passes 02 PACK_ITER 4 → 24 The leftover overlap sum after the first round drops from 39.1 to 0.0, so everything spreads in one round. With 4 it takes three rounds to get down to 0.6
A stiffer spring 05 SPRING_K 0.08 → 0.25 The time to reach 90% of the change drops from 117ms to 67ms, but the overshoot grows from 23.8% to 47.9% of the change
Stronger gravity 08 PULL 0.02 → 0.08 The biggest group radius packs from 61 to 55, but the overlap sum goes from 33.4 to 87.1 and the mean speed from 2.54 to 6.81

These figures come from re-running the source algorithm in Node, not from the screen render.

Before you change a value

Raising SPRING_K shortens the time to reach the target and enlarges the overshoot. The time to settle within 5% also falls from 467ms to 383ms, but the push on neighbors grows, so if the overshoot bothers you, look at SPRING_DAMP together.

PULL is a contest between the speed at which groups gather and the force pushing overlap apart. The figures above are taken 3.2 seconds after the group centers were spread, so a different moment can give different values.

One Frozen Bubble Chart Under Reduced Motion: Accessibility Through a Sentence for the Scene and the Value of the Biggest Bubble

Reduced motion and a hidden tab

All nine sources read the prefers-reduced-motion setting and, when it is on, draw one frozen bubble chart. A hidden tab pauses the clock, and on return it resyncs so the hidden seconds do not pile into one frame.

Keyboard and the spoken sentence

With focus on the stage box, the arrow keys pick a bubble, Enter and Space press it, P pauses, N steps once and R restarts. The canvas is a role="img" with an aria-label describing the scene, and when a lap ends a hidden role="status" announces the name and value of the biggest bubble as a sentence.

The status line turns on at first touch

The status line is aria-live="off" during the demo and becomes polite at the first touch. That keeps speech from pouring out at someone who only watches the demo.

Bubble Chart FAQ: Building Without a Library, Whether Size Means Radius or Area and What You Need Before Putting It on the Web

Do I need a library to build a bubble chart?

No. Canvas 2D is enough, and the nine scenes call no library, no WebGL and no CDN. Relaxation, ticks and label rules are short pure functions, so you can cut out only the part you need.

Should size mean radius or area?

Area should follow the value. Scaling the radius with the square root of the value doubles the area when the value doubles, while scaling the radius straight to the value makes the area read as four times.

Can I use these scenes as a bubble chart template, and what do I need on the web?

Yes, the zip holds the source of the nine scenes and the functions are small enough to lift into your own page. Give the canvas a role="img" and a description sentence, draw a frozen frame under reduced motion, and accept the arrow keys and a pause key, and since the values sit in arrays the data hookup can come last.

For references, the MDN requestAnimationFrame page covers the draw loop, the Page Visibility API page covers hidden tabs, the KeyboardEvent page covers arrow key handling, and the prefers-reduced-motion page covers the setting. For charts built on the same calculation see the pie chart article and the bar chart article, and for a picture where circles find their spot by force see the force directed graph article.

Enter the archive password

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