How the Gouache hero works, why its dye texture stores concentrations instead of colours, and the three passes that took it from a muddy smear to something worth recording.
StackHand-written HTML, CSS, JS. No libraries.HeroRaw WebGL2 stable-fluids solverAssetsNone. All procedural.PayloadUnder 100 KB excluding fonts
01 / Concept
The canvas is the product, so the canvas had to be real.
Gouache is a fictional design collaboration and handoff tool. The pitch is that design stays liquid while handoff stays exact: one canvas everybody paints on, comments that bind to layers instead of coordinates, and specs that come out as code. If that's the promise, a hero image of a fluid would be a lie. The hero had to be the fluid, and it had to still be wet when you look at it.
So the art direction is a white gallery with one live exhibit. The field is paper (#FCFCFA), the type is near-black (#17171C), and the only colour in the entire site comes from four pigment inks: cyan, magenta, yellow and indigo. Everything else is hairlines tinted with indigo rather than neutral gray, because a neutral gray would be the one colour on the page that didn't come out of the paintbox. The gallery framing is literal: crop marks at the corners, and a museum placard in the bottom right that describes the simulation as an exhibit and reports the solver's live resolution and iteration count.
The type does the two-sided job the product does. Hanken Grotesk handles anything that behaves like an interface, tight and unfussy. Instrument Serif italic appears only on the words that are about wetness (liquid, sticks, same, palette, wet), which is the one place the page is allowed to be romantic. Motion follows the same split: the ink is the only thing that moves freely, and the UI stays still so the ink stars.
02 / Technique
Five things doing the heavy lifting.
Dye stores pigment, not colour
The idea the whole hero rests on. Most WebGL fluid demos advect an RGB dye and blend it additively, which is why they all drift toward the same glowing sludge. Here the RGBA dye texture stores the concentration of four inks, one per channel. The display pass converts concentration into colour with Beer-Lambert absorption, so the pigments subtract light the way real ink does. Cyan over yellow gives green. Nothing can drift to a colour that isn't in the palette.
// absorption = -ln(transmittance), floored so a zeroed channel// doesn't go infinite. column i = pigment i.var FLOOR = 0.022;
for (var i = 0; i < 4; i++)
for (var c = 0; c < 3; c++)
m[i * 4 + c] = -Math.log(Math.max(PIGMENTS[i].rgb[c], FLOOR));
// ...and in the display shader, one instruction does the mixing:vec4 c = max(texture2D(uTexture, vUv), vec4(0.0));
vec3 absorb = (uAbs * c).rgb;
gl_FragColor = vec4(uPaper * exp(-absorb), 1.0);
Compact stable-fluids solver
Jos Stam's stable fluids, in raw WebGL2 with a WebGL1 path behind it. Per step: curl, vorticity confinement, a gravity bias, divergence, 22 Jacobi pressure iterations, gradient subtract, then advect velocity and dye. Velocity runs at 132px, dye at 860px. The solver is frame-rate independent via a fixed accumulator, capped at three steps per frame so a slow tab degrades to slow motion rather than to a spiral of death.
// ink is heavier than water: it sinks and mushrooms into a plume.// samples the dye field in velocity space and biases y by total load.vec2 v = texture2D(uVelocity, vUv).xy;
vec4 d = max(texture2D(uDye, vUv), vec4(0.0));
float load = d.x + d.y + d.z + d.w;
v.y -= g * min(load, 2.0) * dt;
gl_FragColor = vec4(v, 0.0, 1.0);
Granulation and the wet edge
What separates paint from a generic fluid demo. Two touches in the display pass: pigment piles up where a wash meets dry paper (the gradient of total load boosts absorption), and pigment settles into the tooth of the sheet (procedural value noise modulates it). Both modulate absorption only, never the base colour, so bare paper stays exactly #FCFCFA and the canvas edge is invisible against the page.
// wet edge: pigment piles up where a wash meets dry paperfloat grad = abs(load(vR) - load(vL)) + abs(load(vT) - load(vB));
absorb *= 1.0 + uEdge * grad;
// granulation: pigment settles into the tooth of the sheetvec2 fc = gl_FragCoord.xy;
float tooth = vn(fc * 0.45) * 0.60 + vn(fc * 1.17 + 19.0) * 0.40;
absorb *= (1.0 - uTooth) + (2.0 * uTooth) * tooth;
Warm to steady state before first paint
The screenshot harness has no mouse, so the idle state is the state. A scripted opening lays down a composition, then a scheduler drops a plume every 0.85 to 1.3 seconds onto one of eight anchors chosen away from the type column, stepping by a coprime stride so consecutive plumes never land together. The solver is then run forward to 4.6 virtual seconds before the canvas is ever shown, budgeted per frame, so the first painted frame is already composed instead of visibly building up.
// budgeted so a slow device degrades to "less evolved",// never to a locked tab
InkField.prototype.warm = function (target, budgetMs, done) {
var self = this; this.warming = true;
(function chunk() {
var t0 = performance.now();
while (self.t < target && performance.now() - t0 < budgetMs)
self.step(1 / 60);
if (self.t < target) return requestAnimationFrame(chunk);
self.warming = false; done();
})();
};
Procedural ink blobs
Every organic blob on the site (comment blooms, pricing bullets, the swatches, the washes on the artboards) comes from one function: a closed Catmull-Rom spline around a radius perturbed by summed harmonics, seeded so it's deterministic. The comment blooms stack three of them, and an SVG filter displaces the edge with turbulence before blurring it, which is the difference between "wet pigment" and "three vector circles".
function blobPath(seed, r, wob, n) {
var rnd = mulberry32(seed), harm = [];
for (var k = 1; k <= 4; k++)
harm.push({ k: k, amp: rnd() / k, ph: rnd() * 6.2832 });
// radius = 1 + wob * (normalised sum of harmonics at angle a)// then Catmull-Rom through the points, closed, as cubic beziers
}
03 / Asset pipeline
There are no assets.
Not one image file ships with this site. Every mark on the page is either type, a hand-drawn inline SVG path, or something generated in code at runtime. That's a deliberate constraint: a site about pigment shouldn't be shipping JPEGs of pigment.
The breakdown. The hero and the closing bleed are two instances of the same WebGL solver, drawing into a canvas each frame. The six studio marks in the proof strip (a folded page, a kestrel, a salt crystal, a horizon sun, an open book, a meridian) are hand-drawn 18px SVG paths on a 1.25px stroke. The five feature diagrams are hand-drawn SVG scenes on a 260×104 viewBox. Every organic blob comes from the blobPath() generator above; six were generated once with a small Node script and baked into the markup as path data, and the comment blooms are generated fresh at runtime with a new seed per pin.
Paper grain is an inline SVG feTurbulence data-URI, tiled at 140px and multiplied over the whole page at 50% so the ink and the paper share one grain. The favicon is an inline SVG data-URI of the four pigment dots. The logo is the same four dots, overlapping, with mix-blend-mode: multiply so they mix subtractively exactly like the hero does, and they pull apart into four pure dots on hover.
04 / Recreate it
The prompt.
Paste this into Claude to get something in the same family. The load-bearing parts are the pigment-concentration constraint and the instruction to warm the solver before first paint. Without those you get a purple smear that fades in.
Copy me
Role. You're an art director and creative developer. Hand-written vanilla HTML, CSS and JS only. No frameworks, no build step, no libraries.
Task. Build a one-page site for a fictional design collaboration tool. The hero is an interactive WebGL fluid simulation on a white field: pointer movement injects coloured dye that swirls like ink in water beneath the headline.
Context. Mood is a white gallery with one live exhibit. Palette: paper #FCFCFA, text #17171C, and exactly four pigment inks (cyan #00B4D8, magenta #E63986, yellow #FFB703, indigo #3A0CA3). Type: Hanken Grotesk for anything interface-like, Instrument Serif italic for display words only.
Format. index.html, styles.css, main.js. A compact stable-fluids solver in raw WebGL2 with a WebGL1 fallback: curl, vorticity confinement, divergence, ~20 Jacobi pressure iterations, gradient subtract, advect.
Constraints.
1. The dye texture stores FOUR PIGMENT CONCENTRATIONS, one per RGBA channel, not an RGB colour. The display pass converts concentration to colour via Beer-Lambert absorption (absorb = -ln(transmittance) per pigment, colour = paper * exp(-absorb)). Pigments must mix subtractively: cyan over yellow makes green, never additive glow.
2. Idle state is the deliverable. With zero pointer input the field must already look composed. Script an opening composition, schedule plumes onto fixed anchors placed away from the type column, and run the solver forward several virtual seconds BEFORE the canvas is first shown, budgeted per frame.
3. Tune dissipation until pigments stay distinct. Too low and old dye smears into uniform haze; too high and the sheet reads empty.
4. Guard the float textures (EXT_color_buffer_float / OES_texture_half_float) and degrade resolution rather than dying. Ship a static fallback if there's no WebGL.
5. Keep display type off the busiest ink with a feathered paper wash. Never a hard-edged backdrop-filter box over live fluid.
6. prefers-reduced-motion: warm the solver once, render one frame, then stop. The painting is finished, not moving.
7. Copy voice: contractions, no em-dashes, blunt and peer-to-peer, no testimonials.
Examples. Ink in water, not a lava lamp. A gallery placard, not a hero badge. Buttons flood with pigment from below on hover, with a meniscus curve on the leading edge.
05 / Iteration log
Three passes, honestly.
Pass 1 Structure
The hero didn't fit in the hero..hero-copy { max-width: 44ch } resolves ch against the element's own 17px font, not the 108px h1, so it came out ~374px wide. The headline wrapped to six lines and pushed the buttons and the placard below the fold. Swapped to a px max-width, restructured the h1 into four explicit lines and dropped the display clamp to 5.85rem.
The ink was migrating. Gravity at 34 plus strong wanderers dragged the whole composition to the bottom left over ~15 seconds, leaving dead white at the top right. Gravity to 14, wanderer force roughly halved, anchors respread.
It was a muddy smear. Dissipation at 0.26 let old pigment linger and average out into a uniform lavender haze. Raised to 0.38, sped the plume cadence to 0.85 to 1.3s and pushed splat amounts up ~40%, so fresh dense plumes read against clean paper.
Washes read as clip art. The blobs on the artboards were hard-edged vector shapes. Blurred them and cut opacity.
Pricing was ragged, three different heights with the CTAs at three different baselines. Stretched the row and pinned the buttons with an auto margin.
The closing bleed was a washed-out smudge. Tightened its centre wash from 52% to 40%, cut its gravity, raised dye density.
Fixed a mobile crop mark sitting on top of the eyebrow pill, and a visible gap before the period after the italic liquid.
Pass 2 Depth
Two bugs found by cropping components at 2x and actually looking. First: .card p is an element+class selector, so it beat every single-class rule nested inside a card and silently reset four of them to 15px (.card-num, .mock-k, .mock-title, .pb-title). Replaced with a .card-d class. Second: the global svg { max-width: 100% } reset was clamping each 128px comment bloom to its 27px pin, which made blooms tiny and knocked them off-centre, since the -64px margin no longer compensated.
Comment blooms became pigment. Three stacked procedural blobs per pin, the outer two displaced by an feTurbulence filter and blurred. Re-anchored them: a 27px square rotated -45° puts its tip 19px below the box centre, so the ink now soaks in where the pin actually touches.
Rebuilt the presence card. It was three cursors drifting in half a card of dead space around a selection rectangle that selected nothing. Now it's a real board: a selection bound to the actual title, three cursors with tightened drift ranges so they stop clumping, and a live typing indicator.
Rebuilt the artboard poster so it's something you'd plausibly leave a comment on: eyebrow, display title, measured text column, CTA, swatches, and two blurred washes bled off the corners.
Added a pigment scroll-progress hairline to the nav, wet-mixing through all four inks left to right.
The placard went live. It now reports the solver's real dye resolution and Jacobi iteration count instead of a decorative string, and says "settled" under reduced motion.
Widened the handoff panel to 272px and reflowed all three code samples so no spec needs sideways scrolling to read.
Pass 3 Final QA
390px mobile: no horizontal overflow, every tap target at or above 44px, display type scales down without breaking.
Guide route built and shot at both widths, sharing the site's stylesheet, palette, nav and grain.
Reduced motion verified: the solver warms once, renders a single frame and stops. Pins place statically. Nothing loops.
Console clean at both widths. Meta, Open Graph, favicon and skip link all present.
Deployed, then re-shot against the live URL to confirm production renders identically.
06 / Attribution
Who built this.
This site was designed and built entirely by Claude Opus 4.8. The concept, the art direction, the copy, the fluid solver, all three iteration passes and the deploy are its work, start to finish.
The other sites in this showcase were built by Claude Fable 5. That run stopped partway through when Fable's usage credits ran out, and the remaining sites were finished on Opus 4.8 with credit given to whichever model actually did the work. No part of this particular site was built by Fable 5.
Gouache itself is fictional. It isn't a real product, nothing on the site is for sale, and none of the studios named in the proof strip exist. The uptime figure in the pricing table is a product spec on an imaginary product, not a claim about anything real.