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

Ferrofluid Simulation: 9 Canvas Scenes with Spikes

Ferrofluid simulation: A puddle stays flat as a magnet nears, then all spikes rise at once past a threshold. How?

The Puddle Holds Flat, Then Every Spike Rises at Once: Ferrofluid Starts with One Field Threshold

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

The nine scenes share one short rule. Measure the strength the magnet makes at each point, and move the fluid only by the amount that strength passes a threshold. 01 shows the direction of that strength as lines, 02 to 04 as height, gloss and lean, 05 and 06 as the movement of a puddle and droplets, 07 and 08 as a strength that changes over time, and 09 layers those five onto one dish.

What happens in the instant the spikes rise together

In 02 the magnet pole approaches from a depth of 130px to 42px over 0.9 seconds. One pole is scaled so its strength is 1 at a distance of 60px, so the depth where the strength right above it equals 0.45 is 60 ÷ √0.45 = 89.4px by the formula, and frame by frame the threshold is first crossed at 0.63 seconds, at a depth of 88.4px. Until then every cell's height target is 0, so the puddle is a flat plate with only a rim of dome left. After the crossing, spikes start at the center, and at the closest approach the spiked radius widens to √(60² ÷ 0.45 − 42²) = 79px, which is what "all at once" means here: nothing below the threshold, then a wide disc filling fast.

Why the answer comes from screen math, not a lab

This post does not explain how to make or handle real ferrofluid. The strength on screen is a relative value fixed at 1 at a distance of FIELD_REF_PX 60, so it cannot be turned into real units, and the fluid color FLUID [13,12,17] is a picture value held at near black. These ferrofluid examples instead put the properties you want to check by eye, threshold, lean and chains, into a few lines of math, so you can change a number and watch the screen change.

How this differs from the neighboring canvas physics posts

The magnet particles in building a physics simulation handle only the force that pulls one point, and the soap bubble effect centers on painting color from film thickness. Here a strength field is laid over the whole screen and a height grid is stood on top of it. Where a physics engine keeps shape with pressure, the puddle in 05 adds a slope force from the magnet on top of that pressure, and the yardstick for calculated scene effects is in the water drop and black hole canvas effects.

Measure the Field with the Dipole Formula, Stack Three Crossed Waves into Spikes, Add Gloss from the Height Slope

Each scene picks and combines three shared functions. The table below fixes what each step decides, and the sections after it open them one at a time.

Step Function What it decides Scenes using it
1 Measure the field fieldAt Strength and direction summed over the poles, with a distance floor soften 01 to 05 and 07 to 09 (06 uses one chosen field direction)
2 Raise spikes hexWave A hexagonal pattern from 0 to 1 at spacing spacing 02, 03, 07, 08, 09
3 Add gloss shadeOne and shadeGrid A reflected highlight color from the slope 02, 03, 07, 08, 09

Strength summed over each pole and an 18px distance floor

A pole is a signed point {x, y, q}, and the vector to the measured point gets soften as a third component, so the distance cannot shrink below it even right above a pole. In 01 this value is 18px, so looking at one pole alone, the strength right above it stops at (60 ÷ 18)² = 11.11. The three components stay in the global field array, so a scene like 04 that needs the direction reads it without recomputing.

    const FIELD_REF_PX = 60;
    const field = [0, 0, 0];
    function fieldAt(poles, x, y, soften) {
        let bx = 0;
        let by = 0;
        let bz = 0;
        const ref2 = FIELD_REF_PX * FIELD_REF_PX;
        for (const p of poles) {
            const dx = x - p.x;
            const dy = y - p.y;
            const r2 = dx * dx + dy * dy + soften * soften;
            const k = p.q * ref2 / (r2 * Math.sqrt(r2));
            bx += dx * k;
            by += dy * k;
            bz += soften * k;
        }
        field[0] = bx;
        field[1] = by;
        field[2] = bz;
        return Math.sqrt(bx * bx + by * by + bz * bz);
    }

Why three waves 120° apart make a hexagonal array

Adding three cos waves with directions 0°, 120° and 240° gives a sum between −1.5 and 3, and only the points where the three crests meet come close to 3. With the wave number set to 4π ÷ (√3 × spacing), the distance between those meeting points is exactly the spacing, so they sit like a honeycomb. In the sum divided by 3, anything below 0.3 is cut to 0 to leave the space between spikes flat, and the part above is stretched to 0 to 1, which gives a cone that narrows straight toward the tip.

The path from a height difference to a highlight

The surface direction is the vector (−∂z/∂x, −∂z/∂y, 1) from neighboring cell height differences, reduced to length 1. Taking its dot product with the direction halfway between the light and an eye looking straight on, the result is sharpened by SHINE squared, and two lights, pink [255,190,210] and sky blue [172,212,255], are added in turn. As much as the surface tilts, up to half of a violet FLUID_EDGE [86,80,112] is mixed into the black base, so even a slope with no highlight keeps a visible outline. The grid picture is written one cell per pixel to a hidden canvas with putImageData and then stretched to the dish size with drawImage, smoothly.

Ferrofluid Simulation: 9 Scenes — Field Lines Traced from the Dipole Formula, Hexagonal Spikes That Rise Past a Critical Field, Black Gloss Built from the Height Slope, Spikes That Lean Along the Field, A Puddle Dragged by the Magnet While Tension Keeps It Round, Droplet Chains That Line Up with the Field, Spikes That Pulse to a Beat Envelope, A Ferrofluid Clock That Raises the Time with a Magnet Grid and A Ferrofluid Interior Scene on a Glass Dish

Values from 24 frames over 2 seconds at 480×300

The table comes from a 2-second render of every scene at 480×300 with 24 captured frames, which leaves 23 neighbor-to-neighbor differences to count.

Scene Area covered Frames that moved Mean intensity
01 Dipole field lines 43.6% 23/23 97.6
02 Hexagonal spikes past a threshold 9.8% 23/23 63.3
03 Height slope gloss 14.4% 23/23 38.3
04 Spikes leaning with the field 16.9% 23/23 95.5
05 Puddle dragged and held round 30.5% 23/23 113.5
06 Droplet chains 63.1% 23/23 57.1
07 Spikes pulsing to a beat 6.0% 23/23 64.4
08 Magnet grid clock 14.2% 23/23 29.6
09 Glass dish scene 22.1% 23/23 79.4

06, where 140 droplets gather and scatter across the whole plate, covers the widest area at 63.1%. 07 keeps its magnet fixed so only the cluster of spikes in the middle rises and falls, which likely explains its 6.0%, and still all nine scenes changed on 23 of 23 frame pairs.

01Field Lines Traced from the Dipole Formula

A figure for the opening of a magnetism unit on a science learning site: a bar magnet travels a 44px by 30.8px ellipse every 6 seconds while lines start from 14 points around the N pole, advance 4px at a time along the local field direction and are redrawn every frame, 17 to 18 of them. Each step reads the direction again from half a step ahead, which makes it a two-stage integration, and a line stops within 9px of the S pole or 24px beyond the screen edge. Dashes flow along the lines in proportion to strength, 205px/s beside a pole and 2.36px/s 140px away, an 87-fold gap.

Field Lines Traced from the Dipole Formula
Open in a new tab

01 · Field Lines Traced from the Dipole Formula — Measured area 43.612% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FIELD_SOFTEN_PXSTEP_PXFLOW_SPEED
        for (let s = 0; s < MAX_STEPS; s += 1) {
            dirAt(x, y, sign, d1);
            const mx = x + d1[0] * STEP_PX / 2;
            const my = y + d1[1] * STEP_PX / 2;
            const bm = dirAt(mx, my, sign, d2);
            x += d2[0] * STEP_PX;
            y += d2[1] * STEP_PX;
            // flow time = step length ÷ speed (FLOW_SPEED × strength). In faster places the dashes travel farther in the same time
            t += STEP_PX / (FLOW_SPEED * Math.max(1e-3, bm));
            L.x.push(x);
            L.y.push(y);
            L.t.push(t);
            L.b.push(bm);
            if (Math.hypot(x - target.x, y - target.y) < SEED_RADIUS_PX) {
                reached = true;
                break;
            }
            if (x < -EDGE_PX || x > W + EDGE_PX || y < -EDGE_PX || y > H + EDGE_PX) {
                break;
            }
        }

02Hexagonal Spikes That Rise Past a Critical Field

Opening background for a speaker product page: while the magnet pole approaches the puddle stays flat, and spikes start to rise from the center at 0.63 seconds, when the center strength passes the threshold of 0.45. A cell's height target is 18 times the amount over the threshold rather than the strength itself, so at the closest approach the center target is 18 × (2.04 − 0.45) = 28.6px. A spring with stiffness 80 and damping 8 chases it, entering the ±3% band around the target at 1.17 seconds, and the center cell peaks at 30.3px at 1.25 seconds, when the magnet has begun to slide sideways and the target is already down to 21.7px.

Hexagonal Spikes That Rise Past a Critical Field
Open in a new tab

02 · Hexagonal Spikes That Rise Past a Critical Field — Measured area 9.821% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CRITICAL_FIELDSPIKE_SPACING_PXSPIKE_STIFFNESS
    // measure the strength at each cell and set the height target — 0 below the threshold, the amount over it above
    function measure() {
        poles[0].x = magX;
        poles[0].y = magY;
        peak = fieldAt(poles, magX, magY, depth);
        for (let j = 0; j < grid.gh; j += 1) {
            for (let i = 0; i < grid.gw; i += 1) {
                const k = j * grid.gw + i;
                const b = fieldAt(poles, i * CELL_PX, j * CELL_PX, depth);
                grid.target[k] = grid.wet[k] > 0.5 ? SPIKE_GAIN_PX * Math.max(0, b - CRITICAL_FIELD) : 0;
            }
        }
        simStatus = peak < CRITICAL_FIELD
            ? fill(STATUS_FLAT, { b: peak.toFixed(2), c: CRITICAL_FIELD })
            : fill(STATUS_SPIKE, { b: peak.toFixed(2), c: CRITICAL_FIELD, r: Math.round(spikeRadius()) });
    }
    // height = dome + cell spring height × three waves
    function compose() {
        const n = grid.gw * grid.gh;
        for (let k = 0; k < n; k += 1) {
            grid.z[k] = dome[k] + grid.amp[k] * hex[k];
        }
    }

03Black Gloss Built from the Height Slope

Glossy background for a dark mode product landing page: two pastel lights, pink and sky blue, circle the spike field of two magnets once every 4 seconds and leave a highlight on each tip. The same scene is split in two, so the left side measures four neighbor heights again at every screen pixel while the right side measures each cell once on a 3px grid of 8,181 cells and stretches the result. With a frame budget of 9,000 pixels the left side changes one picture every 8 frames at device pixel ratio 1 and every 32 frames at ratio 2, and the right side changes every frame.

Black Gloss Built from the Height Slope
Open in a new tab

03 · Black Gloss Built from the Height Slope — Measured area 14.402% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHINECELL_PXFRAME_BUDGET_PX
    function pixelPass(budget) {
        const hv = halfVectors(passLights);
        const step = 1 / passScale;
        const total = passW * passH;
        let done = 0;
        while (done < budget && passAt < total) {
            const px = passAt % passW;
            const py = Math.floor(passAt / passW);
            const x = (px + 0.5) * step;
            const y = (py + 0.5) * step;
            const gx = (heightAt(passPoles, x + step, y) - heightAt(passPoles, x - step, y)) / (2 * step);
            const gy = (heightAt(passPoles, x, y + step) - heightAt(passPoles, x, y - step)) / (2 * step);
            shadeOne(gx, gy, wetAt(x, y), DISH, hv, SHINE, work, passAt * 4);
            passAt += 1;
            done += 1;
        }
        passFrames += 1;
        if (passAt >= total) {
            shown.set(work);
            lastPassFrames = passFrames;
            startPass();
        }
        return done;
    }

04Spikes That Lean Along the Field

Moving background for a portfolio hero: when a bar magnet passes left and right once every 4.4 seconds under 76 side-view spikes in three rows of 31, 25 and 20, each spike lies down along the field axis where it stands. The lean target is the axis angle times 0.9 clamped at ±60.2°, and the height target is the amount by which the vertical component passes 0.35, so no spike rises above the middle between two poles, where the vertical is 0 and the horizontal 2.33. The angle spring (60 and 9) trails behind, and over one lap the most tilted spike reaches 70.7°, beyond the cap.

Spikes That Lean Along the Field
Open in a new tab

04 · Spikes That Lean Along the Field — Measured area 16.903% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LEAN_GAINLEAN_MAXLEAN_STIFFNESS
    // axis angle of the field — clockwise positive from straight up (0). Spikes follow the line's axis, not the arrow, so past ±π/2 the angle is folded by half a turn
    function fieldAxisAngle() {
        let a = Math.atan2(field[0], -field[1]);
        if (a > Math.PI / 2) {
            a -= Math.PI;
        }
        if (a < -Math.PI / 2) {
            a += Math.PI;
        }
        return a;
    }
    function aim() {
        for (const s of spikes) {
            const r = ROWS[s.row];
            fieldAt(poles, s.x, r.y, Math.hypot(LEAN_SOFTEN_PX, r.depth));
            s.lean.to = clamp(fieldAxisAngle() * LEAN_GAIN, -LEAN_MAX, LEAN_MAX);
            // height comes from the vertical component |by| that pierces the surface — spikes do not rise where the field lies flat, as between two poles
            const normal = Math.abs(field[1]);
            s.h.to = Math.min(SPIKE_MAX_PX, SPIKE_GAIN_PX * Math.max(0, normal - CRITICAL_FIELD)) * r.scale;
        }
    }

05A Puddle Dragged by the Magnet While Tension Keeps It Round

Pull effect on an interactive exhibition page: a magnet drawing a sideways figure eight every 3 seconds drags the 40 points around a 46px puddle. Each point sums a pull from the slope of the squared strength, a return toward the midpoint of its two neighbors, a pressure that keeps the starting area and viscous damping, so the area stays within 0.93 to 1.04 times. When the magnet runs ahead the puddle stretches into a teardrop and follows, and the tail left behind peaks at 62.8px at 0.75 seconds.

A Puddle Dragged by the Magnet While Tension Keeps It Round
Open in a new tab

05 · A Puddle Dragged by the Magnet While Tension Keeps It Round — Measured area 30.456% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SURFACE_TENSIONAREA_PRESSUREPUDDLE_DT_MAX
    function ringAccel(r, poles, p) {
        const n = r.n;
        const area = ringArea(r);
        const squeeze = p.pressure * (r.area0 - area) / Math.max(1, Math.abs(r.area0));
        for (let i = 0; i < n; i += 1) {
            const a = (i + n - 1) % n;
            const b = (i + 1) % n;
            const mx = (r.x[a] + r.x[b]) / 2;
            const my = (r.y[a] + r.y[b]) / 2;
            // outward direction — the direction (tx, ty) joining the neighbors turned to (ty, −tx). On a ring placed so the area is positive, this side is outward
            const tx = r.x[b] - r.x[a];
            const ty = r.y[b] - r.y[a];
            const tl = Math.hypot(tx, ty) || 1;
            const g = strength2Grad(poles, r.x[i], r.y[i], p.soften);
            ringAx[i] = p.drag * g[0] + p.tension * TENSION_RATE * (mx - r.x[i]) + squeeze * (ty / tl) - p.viscosity * r.vx[i];
            ringAy[i] = p.drag * g[1] + p.tension * TENSION_RATE * (my - r.y[i]) + squeeze * (-tx / tl) - p.viscosity * r.vy[i];
        }
    }

06Droplet Chains That Line Up with the Field

Magnetic field demo for physics class material: 140 droplets join into chains during the 2.6 seconds the field is on and drift apart during the 1.4 seconds it is off. The force on a pair is proportional to (3cos²θ − 1)/ρ⁴ from the angle θ between their joining line and the field, so pairs lined up with the field attract and pairs side by side repel, while the sin 2θ part turns the joining line toward the field. Chains number 52 at 0.2 seconds after the field switches on, and across 99 linked pairs at 2.4 seconds the joining lines miss the field direction by 14.9° on average.

Droplet Chains That Line Up with the Field
Open in a new tab

06 · Droplet Chains That Line Up with the Field — Measured area 63.08% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CHAIN_GAINCUTOFF_PXLINK_RATIO
                                const ux = dx / r;
                                const uy = dy / r;
                                let px = 0;
                                let py = 0;
                                if (fieldOn > 0) {
                                    const cos = ux * fx + uy * fy;
                                    const sin = fx * uy - fy * ux;
                                    const rho = Math.max(r, dia * 0.9) / dia;
                                    const k = CHAIN_GAIN * fieldOn / (rho * rho * rho * rho);
                                    const radial = -k * (3 * cos * cos - 1);
                                    const turn = -k * 2 * sin * cos;
                                    px += radial * ux + turn * -uy;
                                    py += radial * uy + turn * ux;
                                }
                                if (fieldOn < 1) {
                                    const s = SCATTER_GAIN * (1 - fieldOn) * (1 - r / CUTOFF_PX);
                                    px += s * ux;
                                    py += s * uy;
                                }
                                a.fx += px;
                                a.fy += py;
                                b.fx -= px;
                                b.fy -= py;

07Spikes That Pulse to a Beat Envelope

Now-playing indicator for a music player: each of 10 beats drawn from seed 7, with gaps of 0.32, 0.48 or 0.64 seconds, makes a curve that rises fast and fades slowly and swings the field strength multiplier between 1.00 and 3.37. A beat of strength 1 reaches its peak at 0.066 seconds and halves by 0.312 seconds, and between beats the center strength of 0.9, twice the threshold, keeps a few spikes standing. The cell spring (140 and 9) overshoots a strong beat's target by up to 15.7px and stands the tallest spike, 56.4px, at 2.55 seconds, with no sound file or microphone.

Spikes That Pulse to a Beat Envelope
Open in a new tab

07 · Spikes That Pulse to a Beat Envelope — Measured area 5.995% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ATTACK_SDECAY_SENVELOPE_GAIN
    function makeBeats() {
        const rng = makeRng(BEAT_SEED);
        beats = [];
        for (let t = FIRST_BEAT_S; t < LOOP_S - 0.3; t += BEAT_GAPS[Math.floor(rng() * BEAT_GAPS.length)]) {
            beats.push({ t: t, s: 0.45 + rng() * 0.55 });
        }
    }
    function envelopeAt(t) {
        let e = 0;
        for (const b of beats) {
            const tau = t - b.t;
            if (tau < 0) {
                continue;
            }
            e += b.s * (1 - Math.exp(-tau / ATTACK_S)) * Math.exp(-tau / DECAY_S);
        }
        return e;
    }

08A Ferrofluid Clock That Raises the Time with a Magnet Grid

Clock widget for a lounge waiting screen: in a 40×25 grid of magnets 12px apart only the ones under four 5×7 dot digits and the colon are switched on, and the field is summed again only when the time changes. The virtual clock starts at 00:41 from the seed and advances 7 minutes every 1.1 seconds, while fluid is pulled toward stronger neighboring cells and rides a spread that dies out with a 0.25-second time constant after each change to settle on the new digits. Because what leaves a cell is added to its neighbors, the total of 64 is conserved to a worst error of 6.6×10⁻⁶ per lap, and 1 second after a change 98 to 99% sits on the new digits.

A Ferrofluid Clock That Raises the Time with a Magnet Grid
Open in a new tab

08 · A Ferrofluid Clock That Raises the Time with a Magnet Grid — Measured area 14.197% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

POLE_QPULL_RATESTEP_MIN
                for (let q = 0; q < 4; q += 1) {
                    const j = nb[q];
                    if (j < 0) {
                        continue;
                    }
                    const bi = coarseField[i];
                    const bj = coarseField[j];
                    let f = liquid[i] * PULL_RATE * h * Math.max(0, bj - bi) / (bj + bi + PULL_BIAS);
                    // spread once per pair (from the i < j side), moving half of the difference on each side
                    if (j > i) {
                        f += spread * h * (liquid[i] - liquid[j]) / 2;
                    }
                    outs[q] = f;
                    outSum += Math.max(0, f);
                }
                const cap = outSum > liquid[i] * 0.5 ? liquid[i] * 0.5 / outSum : 1;
                for (let q = 0; q < 4; q += 1) {
                    const j = nb[q];
                    if (j < 0) {
                        continue;
                    }
                    const f = outs[q] > 0 ? outs[q] * cap : outs[q];
                    flowTmp[i] -= f;
                    flowTmp[j] += f;
                }

09A Ferrofluid Interior Scene on a Glass Dish

Product hero for an interior decor shop: a bar magnet circles under a 128px glass dish once every 6 seconds and drags the ring puddle of 05, with the spikes of 02, the lean of 04, the gloss of 03 and the field lines of 01 layered on top. The lean is read with an offset of 6px × the lean for higher wave values, so a spike body looks bent to one side. The share of cells with a spike swings between 36 and 60%, and the distance between the magnet and the puddle's center of mass grows from 2.7px at 1 second to 19.1px at 5.5 seconds.

A Ferrofluid Interior Scene on a Glass Dish
Open in a new tab

09 · A Ferrofluid Interior Scene on a Glass Dish — Measured area 22.147% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAG_ORBIT_RLEAN_SHIFT_PXDOME_PX
    // height = puddle dome + spike height × the wave value read with a lean offset
    function compose() {
        for (let j = 0; j < grid.gh; j += 1) {
            for (let i = 0; i < grid.gw; i += 1) {
                const k = j * grid.gw + i;
                let p = 0;
                if (grid.amp[k] > 0.05) {
                    const x = grid.ox + i * CELL_PX - DISH_X;
                    const y = grid.oy + j * CELL_PX - DISH_Y;
                    const p1 = hexBase[k];
                    p = hexWave(x - leanX[k] * LEAN_SHIFT_PX * p1, y - leanY[k] * LEAN_SHIFT_PX * p1, SPIKE_SPACING_PX);
                }
                grid.z[k] = DOME_PX * wetRaw[k] + grid.amp[k] * p;
            }
        }
    }

How Does Ferrofluid Work When You Rebuild It on a Canvas?

A spike leans toward the axis of the field line passing through its spot, not toward the magnet. fieldAxisAngle in 04 treats the field vector as a line, not an arrow, and folds it back by half a turn once it passes ±90° from vertical. So spikes above the N pole and above the S pole stand to the same side even though the field signs are opposite, and where the field lies nearly flat, as between two poles, the height goes to 0 instead of the lean.

The instant the lean flips

When the axis crosses horizontal as the magnet passes, the target flips straight from +60° to −60°. Over one lap in the node measurement the largest gap between target and actual lean was 129.7°, and the angle spring wraps that gap into −π to π and turns the short way. The forest of spikes lying down and standing up in turn like a wave seems to come from that slow spring.

Screens that fit it

For the first screen of a decor shop product page, hang 09 as it is and set the mood with the liquid on the dish being slowly dragged along instead of a product photo. A page that wants to tie into sound suits the threshold scene 02 or the beat scene 07, and 07 pulses from a seeded beat with no sound file, so a small mark next to a play button works too. A ferrofluid animation for teaching reads faster as 01's lines and 06's chains side by side than as a description of uses, and 08 fits a lounge or store waiting screen, though it uses a virtual time now, so showing real time takes one more step of feeding an outside time into the function that advances the clock.

Where a Ferrofluid Scene Breaks: a Field That Shoots Up Right Above the Magnet, Slopes Recomputed for Every Pixel and One Long Frame After a Hidden Tab

Without a distance floor the strength beside a pole shoots up to 14,401

The section graph under 01 measures a straight line through both poles, ±72px at every 1px, and overlays a version with a distance floor of 18 and a version with a floor of 0. The maximum is 10.96 with the floor of 18 and 14,401 with the floor of 0, 1,314 times higher, which punches out of the top of the graph. A sample sits at a pixel center, so the nearest point to a pole is 0.5px away and the squared distance falls to 0.25, giving 60² ÷ 0.25 = 14,400. Feed that into the 02 formula and the target for that one cell alone becomes about 18 × 14,400, rising off the screen.

    // two sections — strength with the distance floor and strength with a floor of 0
    function measureSection() {
        sectionSoft = [];
        sectionHard = [];
        const ux = Math.cos(magA);
        const uy = Math.sin(magA);
        for (let k = -SECTION_HALF_PX; k < SECTION_HALF_PX; k += 1) {
            const u = k + 0.5;
            const x = magX + ux * u;
            const y = magY + uy * u;
            sectionSoft.push(fieldAt(poles, x, y, FIELD_SOFTEN_PX));
            sectionHard.push(fieldAt(poles, x, y, 0));
        }
    }

Recomputing the slope at every pixel takes 32 frames for one picture

The left panel of 03 measures four neighbor heights again for every pixel, so one picture is 72,000 pixels and 288,000 height lookups at ratio 1, and 288,000 pixels and 1,152,000 lookups at ratio 2. Divided by the frame budget of 9,000 pixels, the picture changes once per 8 frames and once per 32 frames, and meanwhile the top left of the screen reads "the same picture for N frames". The 3px grid on the right is 8,181 cells whatever the ratio, so it repaints every frame. The higher the display ratio, the slower the per-pixel method gets, so a smoothly changing value like height is better measured on a grid and stretched.

The 0.8-second frame after a hidden tab

05 pushes 0.8 seconds into one frame at 1.15 seconds to imitate a tab that was hidden and came back. The left puddle, which used the received time as one step as it is, sends one point 3,116px away and flips its area to −566 times, a twisted polygon, while the right puddle, clamped at 0.05 seconds and split into 1/240 second substeps, moves its farthest point only 12.7px and keeps 0.96 times its area. Looking at tension alone, the longest frame a raw single step survives is 0.101 seconds, so 0.8 seconds is close to eight times that.

    // one step — use the received dt as is, all at once (velocity first, then position). No clamping and no splitting
    function stepRingRaw(r, poles, p, dt) {
        ringAccel(r, poles, p);
        for (let i = 0; i < r.n; i += 1) {
            r.vx[i] += ringAx[i] * dt;
            r.vy[i] += ringAy[i] * dt;
            r.x[i] += r.vx[i] * dt;
            r.y[i] += r.vy[i] * dt;
        }
    }
    // split stepping — clamp dt at PUDDLE_DT_MAX, cut it into substeps of PUDDLE_SUBSTEP or less and repeat the step above
    const PUDDLE_DT_MAX = 0.05;
    const PUDDLE_SUBSTEP = 1 / 240;
    function stepRingSafe(r, poles, p, dt) {
        let left = Math.min(dt, PUDDLE_DT_MAX);
        while (left > 1e-9) {
            const h = Math.min(PUDDLE_SUBSTEP, left);
            stepRingRaw(r, poles, p, h);
            left -= h;
        }
    }

All nine scenes stop on visibilitychange when the tab hides and, on return, set the clock again so the hidden time is not passed on as the first frame length. The password for the downloaded zip is zm79qtgy, and opening only the index.html in folder 05 in a browser lets you catch the moment the left puddle twists and bursts at 1.15 seconds with the pause button. The burst shape is held in place, so it is easy to compare with the right puddle beside it.

Lower the Threshold and How Early Do Spikes Rise? Critical Field, Surface Tension and Field Softening Changed

Name Changed value Feel
Spikes that rise sooner 02 CRITICAL_FIELD 0.45 → 0.3 Start 0.63 s (depth 88.4px) → 0.48 s (109.3px), spiked radius 79px → 101.2px, center target 28.6px → 31.3px
More than double the surface tension 05 SURFACE_TENSION 0.22 → 0.5 Area 0.93 to 1.04 → 0.82 to 1.03 times, roundness 0.66 → 0.68, tail 62.8px → 65.4px, longest raw single step 0.101 s → 0.067 s
Less softening right above the magnet 01 FIELD_SOFTEN_PX 18 → 6 Strength above a pole 11.1 → 100 (9 times), flow beside a pole 205 → 726px/s, lines 17 to 18 → 18

Numbers to check before changing a value

Lowering the threshold to 0.3 makes spikes rise 0.15 seconds sooner along the same magnet path, and the center target goes up by SPIKE_GAIN_PX × 0.15 = 2.7px. Raising surface tension to 0.5 made the tail 2.6px longer instead, since this force only pulls toward the neighbor midpoint to smooth the rim and does not directly limit how far the puddle stretches. What changes is the stiffness, 2 × 0.5 × 900 = 900, which cuts the longest raw single step to 2 ÷ √900 = 0.067 seconds, so when you raise this value it is safer to leave the 1/240 second substep as it is.

Shrinking the distance floor to 6px keeps the line count but makes the strength above a pole 9 times higher, and the dashes beside the pole run 3.5 times faster. Carry that value into a scene like 02 that ties height to strength and only the cells right above a pole may jump, which is exactly the first trap in the section above.

Field Strength You Can Follow Without Seeing Spikes: Accessibility Through a Status Sentence, a Pause Button and the Reduced Motion Setting

What the status line reads out

Each canvas has role="img", an aria-label that describes the scene in one sentence, and tabindex="0". With focus on the canvas, the arrow keys move the control point 8px (24px with Shift held) and Enter or Space returns it to the center, while in 08 alone the arrows, Enter and a press each advance the time one step. The status line starts as aria-live="off" and turns polite after the first input, and in 02 it reads one sentence such as "Strength 2.04 · past threshold 0.45 — spikes reach a radius of 79px" only when the text changes.

Reduced motion and the pause button

Under prefers-reduced-motion: reduce the loop does not run and only one still picture is drawn, with the springs seated straight on their targets at a fixed time, so the picture stays the same as time passes. The dash flow in 01 stops at 0, and 05, 06 and 08 show a still picture computed in one go up to that time. Pressing an arrow key in this state redraws one picture at the new spot and refreshes the status line too, and all nine scenes have a pause button that switches to aria-pressed="true" when pressed. How the announcement works is covered in MDN aria-live and the setting query in MDN prefers-reduced-motion.

Ferrofluid Simulation FAQ: How It Differs from Real Liquid, Heavy Phones and Contrast on a Black Fluid

How does this ferrofluid simulation online differ from the real liquid in demo videos

The spikes in 02, 07 and 09 do not solve how a liquid flows. A height target is set from the strength field, each cell follows it on a spring, and a hexagonal wave pattern at a fixed spacing is multiplied in to shape the spike. So spike spacing does not change with strength and is fixed at SPIKE_SPACING_PX 28px in 02, and the strength numbers are screen-relative values, not real units.

Where do I cut when a phone struggles

Start with the grid cell size. 02 measures 36,391 cells at CELL_PX 2 every frame, and enlarging the cell cuts the count in inverse proportion to its square. fitCanvas caps the canvas pixel ratio at 2, and 06 cuts the pair checks to at most 1,127 (against 9,730 for all pairs) with a 44px cell grid. Keeping the field sum in a precomputed table is what 07 (one strength-1 table times a multiplier) and 08 (summing only when the time changes) already do.

Are the highlights and text readable on a black fluid

The fluid color FLUID is kept at near black [13,12,17] and left out of the pastel conversion, because the pink and sky blue highlights only catch on spike tips when this color is dark. The status line and buttons sit outside the canvas as $subject-cream text on $subject-ink. How a picture is written to a canvas is in MDN putImageData, and the tab hidden event in MDN visibilitychange.

Enter the archive password

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