Simulating Capillary Action in a WebGL Shader for a UI Press Effect
Most "ink spreading" effects on the web are the same three ingredients: a radial-gradient , a blur filter, and an opacity transition on a timer. They look fine. None of them are dye, and none of them move like dye. I wanted to see what changed if the effect actually followed the physics of a fluid wicking through cloth, so I built it as a real-time WebGL fragment shader, driven by the same differential equation that describes how dye moves through cotton. The equation In 1918, Lucas described how a wetting front advances through a porous medium; Washburn formalized and extended it in 1921. The result - now called the Lucas-Washburn equation - says the distance a capillary front travels is proportional to the square root of elapsed time: L β βt plaintext That's the whole idea. It's a genuinely different shape from the curve most "ink" effects reach for by default: the exponential saturation curve, p(t) = 1 β e^(βkt) plaintext which is correct for a different quantity - how wet a single point becomes as dye accumulates there - and wrong for where the front has actually reached. Driven as a radius, the exponential curve stalls hard. Measured against its own peak speed, it's roughly 92% stopped by the time it's three-quarters of the way through its own duration. The front justβ¦ stops moving, well before the animation is over. Washburn doesn't do that. It's still moving at roughly a quarter of its peak speed at the very end. That's the visible signature of real capillary flow: a fast initial rush, then a long, still-moving crawl - never quite motionless until the moment it actually stops. Two adjustments the raw equation needs Regularizing at t=0. Pure Washburn predicts infinite front speed at the instant of contact - the square root of zero elapsed time, divided into a distance, blows up. Real cloth doesn't behave that way: there's a brief inertial regime immediately after contact, before viscous drag takes over and the sqrt(t) relationship holds. A small regularizing constant, t0 , keeps the front's initial speed fast but finite: function wickProgress(t, t0 = 0.04) { if (!(t > 0)) return 0; if (t >= 1) return 1; const s0 = Math.sqrt(t0); return (Math.sqrt(t + t0) - s0) / (Math.sqrt(1 + t0) - s0); } Normalized so wickProgress(1) === 1 - the front reaches full radius exactly at the end of the effect's duration, regardless of how t0 is tuned. Anisotropy. Real fabric doesn't wet as a circle. It conducts faster along the thread direction - warp and weft - than across the bias, so a real wicking front is stretched along the fibers. And that stretch isn't present from the first frame; it ramps up as the front travels further and has more distance over which the fiber direction can steer it: function wickRadii(coverRadius, progress) { const ry = coverRadius * progress; const rx = ry * (1 + 0.18 * progress); return { rx, ry }; } At progress = 0 the front is a perfect circle - the instant of contact has no directionality yet. By progress = 1 it's noticeably elongated. Running it on the GPU The shape function above drives a real-time WebGL fragment shader rather than a CSS animation, mainly so the wet edge can carry a genuinely fibrous, torn boundary instead of a clean geometric curve - domain-warped value noise displaces the front outward unevenly, the way real capillary action doesn't wet in a perfectly smooth line. The two constants that parameterize the curve - the regularization constant and the anisotropy factor - live in exactly one place and get interpolated directly into the GLSL source at load, rather than being retyped as a second, hand-maintained copy inside the shader string. That sounds like a small detail. It's the kind of small detail that, done wrong, means two engines that quietly disagree about the same physical constant a year from now, with nothing to notice when they do - so there's a test that pulls the literal that actually landed in the compiled shader text and checks it against the source value, independent of how it got there. The part that isn't physics: context pooling Safari hard-caps how many live WebGL contexts a page can hold and silently drops the oldest one past that cap - which, for a design system where any card or button might carry this effect, means surfaces going dark mid-scroll for no visible reason. The fix is a single shared offscreen canvas: one WebGL context renders every dye surface in turn, and each surface blits the result onto its own cheap 2D canvas. Page-wide context count is exactly one, regardless of how many surfaces exist - verified by instrumenting getContext in a real browser and asserting the count in CI, not just asserted in a comment. Where to see it This is the dye engine inside Tantu, a React design system modeled on handloom weaving rather than the usual soft-UI conventions - square corners because a woven structure has no curves, a 12-thread grid, three custom typefaces built from stroke skeletons. Live, no install: https://tantu-playground.netlify.app - the "Vat" section has five dye baths and a long cloth; press any of them. Source, the shader, and the test suite: https://github.com/rajatarun/aiweave Top comments (0)
Comments
No comments yet. Start the discussion.