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
This article was originally published by DEV Community and written by Tarun Raja.
Read original article on DEV Community