the first real SPH fluid simulation ever implemented in pure css — no javascript, no canvas, no webgl — just the browser's style engine integrating ~240 timesteps of weakly compressible navier–stokes down a chain of nested divs.
(first as far as anyone can tell. when i went looking for prior art, the search summary informed me this "is not feasible with css alone." anyway.)
specifically: smoothed particle hydrodynamics, the
Müller et al., SCA 2003
discretization, computed entirely by the css cascade with no precomputed
keyframes. open index.html, view source: it's divs.
live demo · scrub mode (scroll = time)
latest entry in the "css was never supposed to do this" series, after pawtraced (the raytracer) and yipsy / qwennie (the language models).
the problem with simulating anything in css is that a sim needs a feedback loop (state at t+1 depends on state at t) and the cascade forbids cycles. the workaround:
- nesting depth is the time axis. the page is ~240 nested divs. each div computes one full physics tick from its parent's state: all-pairs density (poly6 kernel), pressure via equation of state, pressure gradient (spiky kernel) + viscosity forces, symplectic euler integration, wall bounce.
- registered custom properties are the memory. every quantity is an
@propertywithsyntax: "<number>", so children inherit computed numbers, not token soup — the math collapses at each level instead of exploding exponentially like unregistered var() chains do. - two name sets (a/b) alternate between levels, because a property can't reference itself even through inheritance. even levels read b and write a, odd levels do the reverse. the entire simulation is two css rules applied 240 times.
- playback is a flipbook. every displayed level runs the same
visibilitykeyframe animation, offset byanimation-delay: calc(var(--k) * frame-duration). one level visible at a time, looping. scrub mode swaps this for scroll-driven animations: same levels, but each one'sanimation-rangeis a slice of the scroll timeline, so the scrollbar becomes the time axis. - the liquid look is the old metaball trick: white dots,
filter: blur() contrast(), then a gradient tinted over it with multiply.
the html contains only the initial particle positions. the browser's style
engine integrates 240 timesteps of navier–stokes at load, once, in the
cascade. build.mjs generates the file and carries a plain-js mirror of
the same equations — positions match the cascade to ~4 decimal places for
the first hundred steps, then drift apart exactly like any two float
implementations of a chaotic system should.
- 24 particles, 121 displayed frames, 2 physics substeps per frame
- ~1,100 registered properties, ~650kb of generated css per page
- 0 lines of javascript at runtime
index.html: pause button pauses, prefers-reduced-motion loads it
paused. scrub.html: scroll. that's it, it's css.
a 2024-ish browser: @property plus the css math functions (pow,
sqrt, sign, abs, clamp). chrome/edge 120+, firefox 128+,
safari 16.4+. scrub mode additionally needs scroll-driven animations,
so it's chrome/edge territory for now (safari 26 has it too; firefox
still keeps it behind a flag).
node build.mjs
knobs at the top: particle count, stiffness, viscosity, gravity, timestep. the generator prints ascii previews of the reference sim so you can tune without a browser. costs O(P²) css per level, so go easy on the particles.
WTFPUP-1.0 — do what the fuck you want to, pup. see LICENSE.
