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

Bubble Animation CSS: 9 Soap-Film Scenes That Pop

A soap bubble's rainbow is not painted on — 01 computes color from film thickness, 04 pops from a poked rim that peels back, and 06 grows a curved wall

None of the nine scenes loads a picture. Six of them (01, 02, 04, 06, 08 and 09) compute a fresh value for every pixel they fill, while 03, 05 and 07 draw lines and gradients whose colors come from the same film-thickness math, a thickness in nanometers turned into red, green and blue shares.

Blow, Poke and Join Nine Soap Bubbles

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

The scenes follow one film from the moment it forms to the moment it is gone. Scenes 01 and 02 deal with thickness and the color it produces, 03 with the path a finished bubble takes through the air, and 04 and 05 with what happens when you poke or flick one. Scenes 06 and 07 cover how bubbles join each other and how one tears away from a ring, 08 lets the viewing angle rather than thickness decide the reflection, and 09 puts all of that math onto fourteen bubbles over a meadow at once.

Why Does a Bubble's Rainbow Shift as the Film Gets Thinner?

Light that reaches a bubble's film partly bounces straight off the outer face, and partly enters the film, reflects off the inner face and comes back out. The second beam falls behind by one round trip through the film, a path of 2 × 1.33 × thickness with 1.33 as the refractive index, and depending on how many wavelengths that delay spans, the two beams either add up or cancel. This post sets the wavelengths of red, green and blue at 650, 530 and 450nm, so the same delay leaves a different share in each channel, and that difference is what you see as color. That is why a change in thickness changes the color with nothing painted at all.

Only the outer bounce flips, so a very thin film turns dark

reflectRGB treats only the bounce off the outer face as flipping its phase by half a turn, and uses sin²(2π·1.33·d/λ) as each channel's share. When the thickness d nears 0, the two beams overlap almost exactly out of step and all three values sink together (0.14, 0.21 and 0.28 at 30nm), which is why a crown that has drained very thin shows up dark instead of colored. It is also the reason 02 treats 30nm (BLACK_FILM_NM) as the line where a film is about to burst.

Tilt the view and the same thickness changes hue

Near the edge of a bubble the line of sight crosses the film at a slant, and the drift between the two beams inside the film shrinks by the cosine of the bent angle. insideCos computes that factor as √(1 − (1 − cos²)/1.33²), and because of it the 380nm film of 01 moves from magenta face-on through an orange stage to green, and then to blue toward the rim. Even a bubble with perfectly even thickness gets a ring at its edge in a different color from its middle, and this one line is responsible.

The 0 to 1 values are ratios, not reflectance

The three values are ratios scaled to 0 to 1, not the percentage of light that actually comes back. A real film returns only a few percent, while every scene except 08 multiplies the ratio by 235 (09 uses 200 × amp) and adds the result over its backdrop, the dark stage in most scenes and the meadow in 09, so the bubbles on screen are far brighter and more saturated than real ones. Standing in for each channel with a single wavelength is another approximation, which makes the bands split more sharply than the color you would get by summing the whole spectrum.

Bubble Animation CSS: 9 Soap-Film Scenes — Thickness Color, Drainage, Wind, Popping and Twin Bubbles

One canvas per scene, and a pixel board computed at scene scale

Every scene owns a single <canvas> with internal coordinates fixed at 480×300. Scenes 01, 02, 04, 06, 08 and 09 fill pixel values directly on a separate board and then place it on the stage with drawImage; the board is 250×250 in 01 and 02, 340×172 in 06 and the full 480×300 stage in 09. The detour exists because putImageData ignores any transform on the canvas. The board is computed at scene-coordinate size and drawImage stretches it by the device pixel ratio (capped at 2), so a dense display does not raise the number of pixels to compute. Scenes 03, 05 and 07 fill no pixels and draw only with lines and gradients.

A 1nm lookup table replaces the sin calls

At startup reflectRGB is called exactly once per nanometer from 0 to 1600nm to fill filmLut, a table of 4,803 slots, and when pixels are painted filmAt reads that table with linear interpolation between neighboring nanometers. Scene 01 repaints 62,500 pixels per frame without calling Math.sin once along the way. None of the nine scenes contains getImageData, the call that reads screen pixels back; they only write the values they compute.

Time sliced into fixed 16ms steps

The time between requestAnimationFrame callbacks piles up and is spent in fixed 16ms steps (FIXED_DT). One frame can bank at most 100ms and run at most six steps, and the pixel work happens once per frame no matter how many steps ran. The stylesheet holds no @keyframes; the only thing it animates is a transition on the button background color.

Twenty-four stills per scene, compared pair by pair

Each scene was shot as 24 stills over 2 seconds in a 480×300 window, and each still was set against the one before it, so the last column counts how many of those 23 comparisons found the screen changed.

Scene Cumulative area Mean intensity Moved frames
01 Rainbow from thickness 27.3% 32.6 23/23
02 Drainage and black film 29.1% 48.9 23/23
03 Flock on the wind 42.6% 88.4 23/23
04 Pop from the poke 27.0% 88.6 23/23
05 Wobble springs 13.5% 125.0 23/23
06 Twin bubbles and shared wall 23.9% 64.4 14/23
07 Blowing from a ring 20.7% 96.6 23/23
08 Sky and window reflection 11.6% 41.7 23/23
09 Fourteen bubbles in a meadow 33.7% 32.7 23/23

Scenes 05 and 08 stop at 13.5% and 11.6% of the area because so little of the frame changes. In 05 only the single outline line sways, and in 08 the sky takes 11.4 seconds per turn, so it rotates only about 63° in the 2 seconds while the strongest reflection stays packed into the edge ring. The 14/23 of 06 comes from the still stretch after the twins join, which falls in the back part of the 2-second capture: they finish joining 1.22 seconds in and, with a 1.1-second hold (HOLD_MS), do not part again before the capture ends. The 42.6% of 03 is what 26 bubbles plus flow lines on a 32px grid spread across the whole frame produce.

Where these scenes fit

On a summer event landing page or the first screen of a service for kids, where a clear and light impression matters before anything else, laying 09 down as the hero background and setting the headline on top is the place to start. When a button only needs a bubble pop animation css alone could not compute, a small cutout of 04's poke or 05's wobble costs far less than a scene that fills the screen. Put the ring of 07 where an upload bar would sit and the end of the upload reads as a bubble taking off, while 02 suits a waiting screen that tells the time left through the color of the crown.

01A Soap Bubble Whose Rainbow Is Computed From Film Thickness

For every pixel inside the bubble on a 250×250 board (62,500 pixels in all), thicknessAt sets a thickness from a tilt (0.68× at the crown, 1.32× at the bottom) plus a noise smear that drifts with time, and reflectRGB turns that thickness into shares at three wavelengths, red 650, green 530 and blue 450nm. At the same 380nm, tipping the normal's facing component z from 1 through 0.8 and 0.5 to 0.25 lets insideCos shorten the drift, so a magenta of 0.97, 0.08 and 0.49 passes through an orange of 0.88, 0.65 and 0.00 and a green of 0.29, 0.97 and 0.64 before ending at a blue of 0.04, 0.68 and 0.98. It suits brand hero backdrops and product packaging mockups where the material has to read before any pattern does, and dragging the probe prints that spot's thickness and three values in the left panel.

A Soap Bubble Whose Rainbow Is Computed From Film Thickness
Open in a new tab

01 · A Soap Bubble Whose Rainbow Is Computed From Film Thickness — Measured area 27.32% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FILM_NFILM_D_NMWAVE_RGB_NM
    // Share left at each wavelength when the bounces off the film's front and back faces drift apart by the thickness: sin²(2π·n·d/λ).
    // Only the front-face bounce flips phase, so at thickness 0 all three channels are 0 (black film). The values are 0-1 ratios, not real reflectance (a few %)
    function reflectRGB(dNm) {
        const out = [0, 0, 0];
        for (let c = 0; c < 3; c += 1) {
            const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
            out[c] = s * s;
        }
        return out;
    }
    for (let d = 0; d <= LUT_D_MAX; d += 1) {
        const r = reflectRGB(d);
        filmLut[d * 3] = r[0];
        filmLut[d * 3 + 1] = r[1];
        filmLut[d * 3 + 2] = r[2];
    }
    // Viewed at a slant, light bends inside the film, so the drift shrinks by cos(bent angle). cosView is the cos of the angle between view and normal
    function insideCos(cosView) {
        const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
        return Math.sqrt(1 - s2);
    }

02A Bubble Whose Film Drains Down, Thinning the Crown to Black as the Bottom Bands Climb

The film is cut into 72 bands from crown to bottom, and at every boundary drainStep hands DRAIN_K × sin(boundary angle) × upper band thickness × dt (the time step) to the band below while the sum of thickness × area stays fixed, so whatever the top loses the bottom gains. Drained from the start, the crown falls from 364nm to 209 at 1 second, 120 at 2 and 69 at 3, reaching the 30nm black film at 4.46 seconds, while the 500nm band slides from 38.8° to 51.3° measured from the crown and the 900nm band climbs from 156° to 141°. It fits loading screens and time-remaining cues that announce an approaching end through color, and the demo starts from a bubble already drained for 3.9 seconds, turns the crown black 0.56 seconds later, pops it 0.7 seconds after that and blows a new one.

A Bubble Whose Film Drains Down, Thinning the Crown to Black as the Bottom Bands Climb
Open in a new tab

02 · A Bubble Whose Film Drains Down, Thinning the Crown to Black as the Bottom Bands Climb — Measured area 29.149% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DRAIN_KBLACK_FILM_NMSWIRL_AMP
    // Amount passing down across the boundary between band i and i+1, set by the upper band's thickness. The (thickness × area) sum of the two bands stays fixed, so film volume is conserved
    function drainStep(dt) {
        const h = dt / 4;
        for (let s = 0; s < 4; s += 1) {
            for (let i = 0; i < BAND_N - 1; i += 1) {
                const e = Math.sin((i + 1) / BAND_N * Math.PI);
                const q = DRAIN_K * e * thick[i] * h;
                const moved = Math.min(q, thick[i] * bandArea[i] * 0.5);
                thick[i] -= moved / bandArea[i];
                thick[i + 1] += moved / bandArea[i + 1];
            }
            if (thick[BAND_N - 1] > D_DRIP) {
                thick[BAND_N - 1] = D_DRIP;
            }
        }
        age += dt;
    }

03A Flock of Bubbles Drifting on Buoyancy, Air Drag and a Noise Wind

Twenty-six bubbles step through driftStep, pulled down by 9px/s² (NET_SINK) and braked by a drag of 1.6/s (DRAG_K) times their speed gap from the wind, where windAt treats 2D noise as a stream function, turns its gradient 90 degrees into a swirl field and adds a 16px/s base wind blowing right. The sinking end speed is a slow 9 ÷ 1.6 = 5.63px/s and a speed gap halves in ln2 ÷ 1.6 = 0.433 seconds, so the bubbles follow the wind more than their own weight, and pairs within 90px share a mean velocity-direction cosine of 0.95, pointing almost the same way. Use it for landing backgrounds and event page decor where bubbles should travel as a flock, and press the canvas to push every bubble within a 120px radius outward with a gust of 140px/s at its center.

A Flock of Bubbles Drifting on Buoyancy, Air Drag and a Noise Wind
Open in a new tab

03 · A Flock of Bubbles Drifting on Buoyancy, Air Drag and a Noise Wind — Measured area 42.649% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

NET_SINKDRAG_KWIND_SCALE
    function windAt(x, y, tSec, scale) {
        const z = tSec * WIND_DRIFT;
        const gx = x * scale;
        const gy = y * scale;
        const e = 0.01;
        // Gradient of the stream function ψ in grid units: (∂ψ/∂y, −∂ψ/∂x) is the swirl wind
        const dy = (noise2(gx + z, gy + e) - noise2(gx + z, gy - e)) / (2 * e);
        const dx = (noise2(gx + z + e, gy) - noise2(gx + z - e, gy)) / (2 * e);
        return [WIND_BASE_X + dy * WIND_SPEED, -dx * WIND_SPEED];
    }
    // Very faint sink, weight minus buoyancy (px/s²), pulling down (+y)
    const NET_SINK = 9;
    // Air drag (1/s): proportional to the gap between bubble and wind velocity, and closes that gap
    const DRAG_K = 1.6;
    // One integration step: acceleration = sink + drag × (wind − velocity). Update velocity first, then move position with it
    function driftStep(b, dt, tSec, scale) {
        const w = windAt(b.x, b.y, tSec, scale);
        b.vx += DRAG_K * (w[0] - b.vx) * dt;
        b.vy += (NET_SINK + DRAG_K * (w[1] - b.vy)) * dt;
        b.x += b.vx * dt;
        b.y += b.vy * dt;
    }

04A Pop That Starts Where You Poke, With the Hole's Rim Peeling Back and Spraying Droplets

A hole opens where you press, and stepPop pushes the rim outward in each of 120 directions at rimSpeed, which is RIM_V_K × 560 × √(400 ÷ local thickness), fast at the thin crown (264nm) and slow at the thick bottom (622nm), so speeds inside the bubble range from 447 to 716px/s. Once the rim passes the starting distance of one of the 28 droplets, that droplet leaves outward at 0.35× the local rim speed with a 60px/s upward kick and then falls at 900px/s² (SPRAY_G), and a poke at the center takes 247ms to pop completely. It belongs in click feedback and like reactions where the burst should spread from the pressed spot, and the four spots the demo pokes in turn finish popping in 334 to 395ms.

A Pop That Starts Where You Poke, With the Hole's Rim Peeling Back and Spraying Droplets
Open in a new tab

04 · A Pop That Starts Where You Poke, With the Hole's Rim Peeling Back and Spraying Droplets — Measured area 27.03% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIM_V_KDROPLET_NSPRAY_G
    // Film thickness (nm): thin on top, thick below, mottled by noise. nx and ny run −1 to 1 from the bubble center
    function thicknessAt(nx, ny) {
        return Math.max(420 * (1 + 0.45 * ny) * (1 + 0.18 * noise2(nx * 1.8 + filmT * 0.2, ny * 1.8 + 4.1)), 20);
    }
    function rimSpeed(x, y) {
        const d = thicknessAt((x - BCX) / BR, (y - BCY) / BR);
        return RIM_V_K * RIM_V0 * Math.sqrt(D_RIM_REF / d);
    }
    function stepPop(dt) {
        let open = 0;
        for (let k = 0; k < RIM_N; k += 1) {
            if (rimR[k] >= rimMax[k]) {
                open += 1;
                continue;
            }
            const a = k / RIM_N * Math.PI * 2;
            const v = rimSpeed(hx + Math.cos(a) * rimR[k], hy + Math.sin(a) * rimR[k]);
            rimR[k] = Math.min(rimR[k] + v * dt, rimMax[k]);
        }
        for (const d of drops) {
            if (d.live || d.done || rimR[d.k] < d.at) {
                continue;
            }
            const a = d.k / RIM_N * Math.PI * 2;
            const v = rimSpeed(hx + Math.cos(a) * d.at, hy + Math.sin(a) * d.at);
            d.x = hx + Math.cos(a) * d.at;
            d.y = hy + Math.sin(a) * d.at;
            d.vx = Math.cos(a) * v * 0.35;
            d.vy = Math.sin(a) * v * 0.35 - 60;
            d.live = true;
            spawned += 1;
        }

05A Bubble Whose Outline Wobbles on Layered Spring Modes and Settles

The outline radius is R0 scaled by one plus the sum of the second, third and fourth wobble modes, and springStep gives each mode an acceleration of −ω²·amplitude − 2γ·velocity, with ω from omega growing with n to 8.50, 16.46 and 25.50rad/s and the damping γ rising with n² to 0.55, 1.24 and 2.20. After one flick, the last moment each amplitude exceeds 0.004 is 928ms for mode 4, 2,016ms for mode 3 and 5,760ms for mode 2, so the higher modes quiet down first and leave a slow oval sway, while the stretched part of the outline thins by (R0/r)² and shifts its stroke color. It fits button hover responses and mascot interactions; the total amplitude first dips under 0.004 at 1,856ms, but the oval keeps crossing back over that line and only settles for good from 5,776ms.

A Bubble Whose Outline Wobbles on Layered Spring Modes and Settles
Open in a new tab

05 · A Bubble Whose Outline Wobbles on Layered Spring Modes and Settles — Measured area 13.506% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MODE_MAXMODE_DAMPPOKE_IMPULSE
    function omega(n) { return OMEGA_2 * Math.sqrt((n - 1) * n * (n + 2) / 8); }
    function gamma(n) { return MODE_DAMP * n * n / 4; }
    // One damped spring step: acceleration = −ω²·amplitude − 2γ·velocity. Update velocity first, then move the amplitude with it
    function springStep(dt) {
        for (let n = MODE_MIN; n <= MODE_MAX; n += 1) {
            const w = omega(n);
            const g = gamma(n);
            velC[n] += (-w * w * ampC[n] - 2 * g * velC[n]) * dt;
            velS[n] += (-w * w * ampS[n] - 2 * g * velS[n]) * dt;
            ampC[n] = clamp(ampC[n] + velC[n] * dt, -AMP_CAP, AMP_CAP);
            ampS[n] = clamp(ampS[n] + velS[n] * dt, -AMP_CAP, AMP_CAP);
        }
    }

06Twin Bubbles That Join and Share One Curved Wall Between Them

When the bubbles touch, geometry finds where the two circles cross and sets the shared wall's curvature to p × (1/rs − 1/rl), so at the joined radii of 52 and 84px the wall is an arc of radius rs·rl/(rl − rs) = 136.5px whose center sits on the small bubble's side, which makes it bulge into the large one. The center distance is fixed in advance as the third side of a triangle whose two radii open at 60 degrees, √(rs² + rl² − rs·rl), and junctionAngles measures the angles between the three film tangents at the upper junction, which read 120.00° for all four demo sizes once joined but still lean at 82°, 142° and 136° halfway through (p = 0.5). It suits comparison cards and scenes that link two items, and at equal sizes (84 and 84px) the curvature drops to 0 and the wall stands as a straight line.

Twin Bubbles That Join and Share One Curved Wall Between Them
Open in a new tab

06 · Twin Bubbles That Join and Share One Curved Wall Between Them — Measured area 23.855% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

R_SMALLR_LARGEJOIN_MS
        const x1 = MIDX + (rs - rl - s) / 2;
        const g = { x1: x1, x2: x1 + s, s: s, touching: s < touch - 0.01, hj: 0, xc: 0, flat: true, rw: 0, xw: 0, p: p };
        if (g.touching) {
            // Where the two circles cross: distance a from the small circle's center to the common chord, half chord length hj
            const a = (s * s + rs * rs - rl * rl) / (2 * s);
            g.hj = Math.sqrt(Math.max(rs * rs - a * a, 0));
            g.xc = x1 + a;
            // Shared wall curvature = p × (1/small radius − 1/large radius). Fully joined (p=1), it matches the pressure difference of the two bubbles
            const kappa = p * (1 / rs - 1 / rl);
            if (kappa > 1e-6) {
                g.flat = false;
                g.rw = 1 / kappa;
                // The wall circle's center is on the small bubble's side, so the wall bulges into the large bubble
                g.xw = g.xc - Math.sqrt(Math.max(g.rw * g.rw - g.hj * g.hj, 0));
            }
        }
        return g;
    }
    function junctionAngles(g) {
        const jx = g.xc;
        const jy = MIDY - g.hj;
        const t1 = tangentAway(g.x1, jx, jy, g.x2, rl);
        const t2 = tangentAway(g.x2, jx, jy, g.x1, rs);
        let t3 = [0, 1];
        if (!g.flat) {
            const len = Math.hypot(jx - g.xw, jy - MIDY);
            t3 = [-(jy - MIDY) / len, (jx - g.xw) / len];
            if (t3[1] < 0) {
                t3 = [-t3[0], -t3[1]];
            }
        }
        const ang = function (a, b) {
            return Math.acos(clamp(a[0] * b[0] + a[1] * b[1], -1, 1)) * 180 / Math.PI;
        };
        return [ang(t1, t2), ang(t2, t3), ang(t3, t1)];
    }

07A Bubble Blown From a Ring Until Its Neck Pinches and It Breaks Away

Blowing adds 7,200px² per second (BLOW_RATE) to the area inside the film, solveC halves its search range 50 times through domeArea to find the circle that encloses that area, and from 8,295px², the area at which the film length passes the 251.3px ring circumference, simulate starts to shrink the neck radius. Blowing steadily from a flat film, the neck begins to pinch at 1.156 seconds and the bubble breaks away at 1.560 seconds, when the neck narrows below 0.3× the 40px ring radius, and because film volume is kept by an approximation (D_FLAT × 2R ÷ length), the departing bubble's film has thinned to 192nm. It fits upload and send progress indicators that fill up and then let go in a single moment, and the demo starts from a film already inflated to 7,600px², releasing its first bubble after 0.504 seconds.

A Bubble Blown From a Ring Until Its Neck Pinches and It Breaks Away
Open in a new tab

07 · A Bubble Blown From a Ring Until Its Neck Pinches and It Breaks Away — Measured area 20.721% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BLOW_RATERING_RPINCH_RATIO
    // Circle through both ends of the ring with its center c to the right of the ring: the area on the ring's right side
    function domeArea(c) {
        const rho = Math.hypot(RING_R, c);
        return rho * rho * Math.acos(-c / rho) + c * RING_R;
    }
    // Invert area back to c: area only grows with c, so halve the range to find it
    function solveC(a) {
        let lo = -4000;
        let hi = 800;
        for (let k = 0; k < 50; k += 1) {
            const mid = (lo + hi) / 2;
            if (domeArea(mid) < a) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        return (lo + hi) / 2;
    }
    function simulate(dt, blow) {
        tSec += dt;
        if (blow) {
            area += BLOW_RATE * dt;
        }
        const s = shape();
        if (stage === 0 && s.len >= CIRC) {
            stage = 1;
        }
        if (stage === 1) {
            neck -= NECK_RATE * RING_R * ((s.len - CIRC) / CIRC + NECK_BASE) * dt;
            if (neck / RING_R < PINCH_RATIO) {
                release(shape());
            }
        }

08A Bubble That Mirrors Sky and Window, Reflecting More Toward Its Edge

For every pixel paintBubble uses the sphere normal to get the reflection direction 2z·n − (0,0,1) and reads color from a sky gradient and a window rectangle, then reads once more off the inner face of the back wall with x and y flipped, adding a second window that stands upside down. The blend weight reflectance is EDGE_F0 + (1 − EDGE_F0)(1 − cosθ)⁵, which gives 4% at the center, 9.5% at radius 0.9 and 27.9% at 0.97, so sky color gathers only in a thin ring at the edge while the stage behind shows through at (1 − F)², nearly unchanged. It suits glassy icons and premium card sheen where the middle stays clear and only the rim glows; dragging turns the sky sideways, and while turning is on it spins once every 11.4 seconds by itself.

A Bubble That Mirrors Sky and Window, Reflecting More Toward Its Edge
Open in a new tab

08 · A Bubble That Mirrors Sky and Window, Reflecting More Toward Its Edge — Measured area 11.57% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

EDGE_F0SKY_TILTWINDOW_GLINT
                const z = Math.sqrt(1 - r2);
                // Incoming view (0,0,−1) mirrored about the normal = 2z·n − (0,0,1)
                sampleEnv(2 * z * nx, 2 * z * ny, 2 * z * z - 1);
                const f0 = env[0];
                const f1 = env[1];
                const f2 = env[2];
                // Once more off the inner face of the back wall: the normal faces inward, so x and y of the reflection flip (a second, upside-down window)
                sampleEnv(-2 * z * nx, -2 * z * ny, 2 * z * z - 1);
                const f = reflectance(z);
                const t = (1 - f) * (1 - f);
                const bo = (clamp(Math.floor(y), 0, H - 1) * W + clamp(Math.floor(x), 0, W - 1)) * 4;
                px[o] = bg[bo] * t + f0 * f + env[0] * f * (1 - f);
                px[o + 1] = bg[bo + 1] * t + f1 * f + env[1] * f * (1 - f);
                px[o + 2] = bg[bo + 2] * t + f2 * f + env[2] * f * (1 - f);
                px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * BR, 0, 1);

09A Meadow Scene Where Fourteen Bubbles Ride the Wind, Each Computing Its Own Color and Reflection

Sky, clouds, hills, grass and 70 flower dots are baked once into a 480×300 pixel array, and every frame copies that array and recomputes only the 14 bubbles (BUBBLE_N) through paintBubble, whose film thickness folds the drainage of 02 into a single expression that thins the top as a bubble ages and then passes through insideCos as in 01. Each bubble lives 0.7 to 1.3 times 6.5 seconds, that is 4.55 to 8.45 seconds, and the first pops of the opening scene come 0.42 seconds apart (0.35, 0.77, 1.19 seconds); a spent bubble bursts from one point on its upper rim with a hole spreading at 420px/s, gone in 50 to 160ms, while a new one drifts in from beyond the left edge. It fits service heroes and the first screen of a summer event landing, and pressing an empty spot moves the sun (SUN_DEG, 38° by default) toward that direction and shifts every highlight with it.

A Meadow Scene Where Fourteen Bubbles Ride the Wind, Each Computing Its Own Color and Reflection
Open in a new tab

09 · A Meadow Scene Where Fourteen Bubbles Ride the Wind, Each Computing Its Own Color and Reflection — Measured area 33.719% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BUBBLE_NSUN_DEGLIFE_S
                const z = Math.sqrt(1 - r2);
                // Film thickness: thicker below, thinning from the top as the bubble ages (drainage), mottled by noise
                const d = Math.max(b.d0 * (1 + 0.3 * ny) * Math.exp(-1.1 * drain * (1 - ny) * 0.5) + 50 * noise2(nx * 2 + b.ph + tSec * 0.3, ny * 2), 0);
                const dEff = d * insideCos(z);
                // Edge reflection: sky when the reflection points up, grass color when it points down
                const m = 1 - z;
                const f = 0.04 + 0.96 * m * m * m * m * m;
                const rx = 2 * z * nx;
                const ry = 2 * z * ny;
                const rz = 2 * z * z - 1;
                const up = -ry;
                const envC = up > 0 ? mix(SKY_LOW, SKY_TOP, Math.min(up, 1)) : GRASS;
                const spec = Math.pow(Math.max(rx * lx + ry * ly + rz * lz, 0), 140);
                const amp = 0.3 + 0.6 * Math.pow(m, 0.6);
                const edge = clamp((1 - Math.sqrt(r2)) * rr, 0, 1) * fade;
                for (let c = 0; c < 3; c += 1) {
                    const v = px[o + c] * (1 - f * 0.8) + envC[c] * f + filmAt(dEff, c) * 200 * amp + spec * 255;
                    px[o + c] = px[o + c] + (v - px[o + c]) * edge;
                }

Which Color Survives When Light Bounces Off Both Faces of the Film?

Calling reflectRGB directly for a face-on view gives the shares below. As the film drains thinner, the color at that spot of the bubble walks down this table from top to bottom.

Thickness Red 650nm Green 530nm Blue 450nm Reads as Where it shows up here
500nm 0.02 1.00 0.02 Green Near the bottom of 01's bubble
380nm 0.97 0.08 0.49 Magenta 01 base thickness
260nm 0.04 0.67 0.99 Blue 04 crown (264nm)
120nm 1.00 0.90 0.63 Pale yellowish white 01 thickness variant, 02 crown around 2 seconds
30nm 0.14 0.21 0.28 Dark bluish gray 02 black-film line
0nm 0 0 0 Black Just before the pop

Why the upper bands of 02 sink while the bottom bands rise

The amount passed across a boundary is proportional to both the upper band's thickness and the boundary circumference (sin θ), so the crown sends out more than it receives from above and thins, and as that thinning spreads downward the spot that satisfies 500nm moves from 38.8° to 51.3°. The bottom band only receives and is capped by the 1500nm overflow limit (D_DRIP), so the thick region widens upward and the 900nm spot climbs from 156° to 141°. The overflow thrown away comes to 0.25% of the film (30,713 to 30,638), which means far more liquid moves up and down inside the bubble than drips off it.

How 04's rim hurries where the film is thin

Speed scales with √(400 ÷ thickness), so a film one quarter as thick sends the rim twice as fast. The film of 04 is 264nm at the crown, 455nm at the center and 622nm at the bottom, thin on top, so poking low makes the hole race upward first while the rim on the bottom side lags. The speed measured at the center is 525px/s, and across the whole bubble it stays between 447 and 716px/s.

In 08 the angle, not the thickness, sets the reflection

Scene 08 has no reflectRGB; the strength of its reflection comes from one quantity, the angle between view and normal, through an approximation in which reflectance climbs as the view grazes the surface. Reflectance passes 10% only outside radius 0.905 and 50% only outside 0.991, so sky color piles up in a thin ring at the rim. The window is white multiplied by 7 (WINDOW_GLINT), so even at the 4% center it lands about 0.04 × 255 × 7 ≈ 71 brighter. Scene 09 writes the same curve out by hand as 0.04 + 0.96·m⁵ with m = 1 − z, a copy of 08's formula at EDGE_F0 0.04 rather than a shared function, so a change to one does not reach the other.

Dozens of Per-Pixel Bubbles, a Film Too Thin, a Paused Tab — Three Places the Scene Breaks

Three spots give way first, and beyond them are a few more edges where the approximations stop holding.

Dozens of bubbles add pixel work in proportion to their area

Scene 01 paints 250×250, or 62,500 pixels, every frame for one bubble of radius 124px. That is why 09 keeps its 14 bubbles small, between 11.5 and 32px in radius: all fourteen together cover about 25,000 bubble pixels, less than half of the 62,500-pixel board that 01 paints each frame. To float dozens of bubbles, shrink the radius before raising BUBBLE_N, and keep pops like 04, which call atan2 and hypot per pixel, to one at a time so the frame rate holds.

Making the flow cubic in thickness keeps the crown from turning black

The drainage of 02 is an approximation in which the flow is proportional to the upper band's thickness, and the share of gravity that pulls along the slope is left out as well. A real film drains closer to the cube of its thickness, and when this source was switched to a cubic flow the crown was still at about 260nm after 4.7 seconds, so the demo no longer showed black film within a few seconds. Adding the slope term causes the same problem, because the crown has almost no slope and barely flows. The 4.46 seconds of 02 is therefore a pace chosen to keep the process visible on screen, not the time a real bubble takes to thin. Without the bottom overflow limit the bottom band, whose area is close to 0, shot up to 300,000nm, and moving at most half a band's volume in one go keeps the small-area crown band from going negative.

A film that gets too thin sends the rim speed flying

The rimSpeed of 04 divides by thickness, so as thickness nears 0 the speed grows without bound. thicknessAt therefore holds thickness at 20nm or more, yet even at that floor the speed is 560 × √20 ≈ 2,504px/s, nearly five times the 525px/s at the center. A real pop is over in a few milliseconds, but here RIM_V0 is slowed to 560px/s so the pop spreads across hundreds of milliseconds, and raising it tenfold would end a center poke in about 25ms, a calculated one tenth of 247ms that fits within two 16ms steps, leaving almost no chance to see the rim peel back.

A tab that comes back catches up by only 100ms

Because a frame banks at most 100ms, hiding a tab and returning does not dump the missed physics into one burst of steps. The life clock of 09 advances by only that much as well, though, so bubbles that should have popped while the tab was hidden are still floating when you return. A screen that has to clear bubbles by wall-clock time needs a separate elapsed-time count apart from this cap.

The 120 degrees hold for two bubbles only

Scene 06 sets the center distance from a formula that makes the three films meet at 120 degrees; it does not solve the pull of the films to find that distance. It cannot be carried over to a foam of three or more bubbles, and since the curvature formula flips sign if the small bubble grows past the large one, the input is clamped to 30 to 84px, no larger than the large bubble's radius. Pressure difference is not computed either; the curvature formula stands in for that relation.

A vertical finger drag belongs to the page

All nine canvases are set to touch-action: pan-y, so a vertical swipe on a touch screen goes to page scrolling. The sky tilt of 08 cannot be changed by a vertical touch drag, so the ↑ and ↓ keys take over that axis.

To change the film thickness or the rim speed yourself, the file set opens with xdxs3ayg; it holds an index.html and a style.scss for each scene, plus React versions that port the same math.

Moving Film Thickness, Rim Speed and Wind Size, Plus Accessibility for Drifting Bubbles

Change Value What you see
Change the film thickness 01 FILM_D_NM 380 → 120 The face-on values move from 0.97, 0.08, 0.49 (magenta) to 1.00, 0.90, 0.63 (pale yellowish white), and the crown, at 0.68×, thins to about 82nm and edges toward black film.
Change the rim speed 04 RIM_V_K 1.0 → 2.5 The center speed rises from 525 to 1,313px/s, a center poke shortens from 247 to 100ms and the four demo spots from 334 to 395 down to 135 to 159ms. The 28 droplets stay 28.
Change the wind scale 03 WIND_SCALE 0.004 → 0.012 One eddy shrinks from 250px to about 83px and the mean flock speed stays nearly level at 34.8 to 34.5px/s, but direction agreement among neighbors within 90px falls from 0.95 to 0.07 and the flock scatters.

The third variant is already built into the Finer wind button of 03 (FINE_X, three times), so you can compare it with Enter or the button before editing any source.

Blowing and poking from the keyboard

Every canvas carries tabindex="0" and an aria-label that says what the scene does, and each scene also places a pair of real <button> elements that work without a mouse. Home returns every one of the nine scenes to its starting state, - and + change the film of 01 by 40nm, and → moves the drainage of 02 forward by 0.6 seconds. In 04 the arrows aim and Enter pokes, ↑ and ↓ resize the small bubble in 06, Space blows in 07, ← and → turn the sky in 08, and ← and → move the sun 10° at a time in 09. The toggle buttons sit in 02, 03, 07, 08 and 09 and report their state through aria-pressed.

A status line that stays quiet through the demo

The status line under each scene sits at aria-live="off" during the automatic demo and switches to polite the moment a person first uses the pointer, a key or a button, which also stops the demo. A screen reader is spared a flood of announcements every time a bubble pops.

The single frame left under reduced motion

With prefers-reduced-motion on, the loop stops and leaves one frame, and all nine scenes listen for the change event, so switching the setting with the page open takes effect right away. Scene 01 shows a film frozen in time, 02 the instant its crown reaches black film, and 06 the two bubbles standing joined. Input still works in this mode: 03 integrates 0.5 seconds and 07 0.4 seconds in one go and shows the result, 04 draws the droplets 0.3 seconds after the pop, 05 draws the dent at the flicked spot at once, and 09 replaces a popped bubble with a new one immediately.

Can a Soap Bubble Effect Run Without Any Image Files?

Yes. The nine scenes use no image files, libraries, CDNs or WebGL, and the six that fill pixels put computed values into an empty board made with createImageData and lift it onto the canvas with putImageData. Where a scene such as 08 or 09 needs a stage to show through the bubble, that stage is also computed into pixels once and read from the stored array, and a single requestAnimationFrame loop drives all the motion. The six scenes that use randomness (01, 02, 03, 04, 06 and 09) draw it from makeRng seeded at 465, so the page starts from the same layout each time it opens; 05, 07 and 08 carry the function's definition but do not call it.

Anyone expecting a floating bubble animation css drives by itself should know the stylesheet here has no @keyframes; it sizes the stage and styles the buttons, and the drifting in 03 and 09 is integrated step by step in the script. For a water drop that bends the screen behind it, continue with the canvas scene effects with a water drop and a black hole, and for bursts reflected on water, the fireworks scenes are the next stop. Other screens that respond through springs and damping are gathered in the physics interaction simulations.

Questions People Ask Before Floating Bubbles Over Their Own Page

Can the bubble colors follow my brand palette?

The rainbow of the film is a computed thickness value, not a palette, so swapping the pastel tokens of the stage and buttons does not change it. The simplest way to move the center of the color is to change FILM_D_NM in 01: following the table above, 500nm centers it on green, 380nm on magenta and 260nm on blue. If you want a single fixed color, painting a flat color in place of this math is the better fit.

What gets heavy first when I add more bubbles?

In a scene that fills pixels, the total bubble pixel count is the work done each frame. Scene 09 has a total of about 25,000 at BUBBLE_N 14, so doubling the count at the same radii roughly doubles the work. Scene 03 draws its flock as outlines of 12 line segments each, so raising FLOCK_N from 26 adds no pixel computation at all.

Does the React version draw the same scenes?

The vanilla index.html and the React component were generated from the same TypeScript source, so the math functions and constant values match. The code excerpts and figures in this post follow the vanilla index.html, and searching the React version for a constant such as FILM_D_NM turns up the same value. To try a scene as a water bubble animation css codepen pen, note that the script inside each index.html has no imports and no external URLs, so it can go into a JS panel as it stands, next to the canvas markup and the compiled stylesheet.

Enter the archive password

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