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

Watercolor Effect CSS: 9 Bleeding Paint Scenes

In a watercolor effect CSS frame, watercolor bleeds come from how water dries, not from the brush: 03 spreads pigment only through wet cells, 02 pools color at

Why It Looks Painted: Stains Shaped by How Water Dries and by the Grain of the Paper

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

A watercolor look on a web page is paint that seems to soak into paper: edges that wander instead of cutting clean, a darker ring where a stain dried, two wet colors blending only where they touch, and grains settling into the dips of the sheet. The CSS here does the framing. .wc sets the board to min(92vw, calc(160vh - 12.8vw)) wide with an aspect-ratio of 480 / 300, rounded corners, a raised shadow and overflow: hidden, while every stroke of paint inside it is computed pixel by pixel on a 2D canvas, with no library, no WebGL and no image file. Even the landscape that 06 converts is calculated on the same page.

01 to 04 each isolate one of those four behaviors, 05 to 08 put them to work as techniques (a dry brush, a picture-to-paint filter, a drop bloom and glazing), and 09 combines the earlier math into a single hero scene. Each scene paints for 3.6 to 4.6 seconds, holds the finished picture, washes it away from the left and starts over with the same seed.

Cafe menus and gallery front pages

On a cafe menu, a single 05 stroke under a section title is plenty. The tail of the stroke frays into separate bristle lines, which reads as handwriting more than a straight underline does, and each stroke is done in about a second, so it gets out of the way of the menu itself. A gallery or an exhibition page suits 09: the order of the wash, the far ridges and the hills tells the visitor that a picture is being made, and the 2.2-second hold after the last stroke leaves time to read the title.

Invitations and brand backgrounds

A mobile wedding invitation header fits 02, since the stain ends up pale in the middle and dark only at the rim, so names and a date placed in the middle stay legible. A brand landing page that only wants a few big patches of color does better with the layered blots of 01 or the grainy bands of 04, and a seasonal sale banner that wants more ornament can float the petal rims of 07 over a wash. Paint that appears once and then rests disturbs the copy less, and that is how these scenes behave: each one paints for 3.6 to 4.6 seconds, and once it's done the picture holds still until the next wash.

Watercolor Effect CSS: 9 Scenes — Layered Blots, Dried Rims, Wet-Cell Bleeds, Paper Grain Pooling, a Splitting Dry Brush, a Photo-to-Paint Filter, Salt Blooms, Glazed Color Mixing and a Landscape Hero

How much of each board changed in a 2-second render

The render tool shot each 480×300 board 24 times over two seconds and compared the 23 neighboring pairs. 09 and 06, which cover most of the board in turn, both passed 50% of the area, while 05, where only four brush strokes cross the paper, stayed the narrowest at 20.8%.

Scene Cumulative area Frames that moved Mean intensity
01 Layered blots 40.5% 17/23 15.9
02 Dried rim 28.0% 20/23 21.0
03 Wet-cell bleed 29.6% 11/23 46.4
04 Paper grain pooling 49.2% 10/23 78.4
05 Dry brush 20.8% 17/23 76.3
06 Picture-to-paint filter 52.0% 12/23 26.1
07 Drop bloom 23.3% 19/23 18.3
08 Glazed color mixing 28.8% 13/23 44.2
09 Landscape hero 54.1% 18/23 36.6

No scene changed in all 23 pairs, most likely because paint lands on a timetable rather than continuously, and 03 (11/23) and 04 (10/23) moved least: 03's sixteen touches come every 0.16 s and then stop, while 04 spends its later seconds on a slow rise of the settling strength, which appears to keep the change between two captures under the threshold. 01 and 07 have the lowest mean intensity because each change is small: one layer of 01 adds 0.042 of density, and a drop in 07 shifts only 20% of the pigment in its band per step.

01Paint Blots Built from Dozens of Faint Layers of Jittered Polygons

Each of the four blots starts from a 10-point outline jittered once into 20 points, and each of its 40 layers re-runs deformPolygon five more times on that base to get a 640-point shape added at LAYER_ALPHA 0.042, so the middle, covered by every layer, builds up to a density of 1.68 while the rim stays thin. The function below pushes the midpoint of every edge along the edge's normal by edge length × BLOT_JITTER (0.45) × 0.5 × a bell-shaped random number, and since shorter edges move less, fine wiggles ride on top of the coarse ones, which suits a flower shop landing page that needs a few large patches of color behind its text. When painting ends the 640-point outline measures 873 px, the average turn at a vertex is 0.755 radians, and density climbs from 10% to 90% of its peak across 4.5 px of the board.

Paint Blots Built from Dozens of Faint Layers of Jittered Polygons
Open in a new tab

01 · Paint Blots Built from Dozens of Faint Layers of Jittered Polygons — Measured area 40.474% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

BLOT_JITTERLAYER_ALPHALAYERS_PER_BLOT
    function deformPolygon(pts, depth, jitter, rng) {
        let cur = pts;
        for (let d = 0; d < depth; d += 1) {
            const next = [];
            const n = cur.length / 2;
            for (let i = 0; i < n; i += 1) {
                const j = (i + 1) % n;
                const ax = cur[2 * i];
                const ay = cur[2 * i + 1];
                const bx = cur[2 * j];
                const by = cur[2 * j + 1];
                const len = Math.hypot(bx - ax, by - ay) || 1;
                const push = normalRandom(rng) * jitter * len * 0.5;
                next.push(ax, ay, (ax + bx) / 2 + (by - ay) / len * push, (ay + by) / 2 - (bx - ax) / len * push);
            }
            cur = next;
        }
        return cur;
    }

02A Stain Whose Pigment Drifts to the Rim as It Dries, Leaving a Dark Ring

Once a stain has finished spreading, distanceToEdge sweeps the grid down and back up to give each cell its distance to the nearest dry cell, and while the water level falls from 1 to 0 over DRY_TIME 2.2 s, flowToRim hands pigment from each wet cell to the neighbors that sit closer to the edge, split by how much closer they are, with a share of RIM_FLOW 2.2 × (0.3 + 0.7 × (1 − level)) that grows as the paper dries. In the excerpt a cell within distance 1 of the edge hits continue and has nowhere to send, so whatever it receives piles up, which makes this the stain for a mobile wedding invitation header with names and a date sitting in the pale middle. After drying, the rim band of the three stains averages 4.95, 5.16 and 5.19 times the density of the inside while their pigment totals (1714.8, 1346.4 and 885.6) stay exactly where they were, so no color was added, only moved.

A Stain Whose Pigment Drifts to the Rim as It Dries, Leaving a Dark Ring
Open in a new tab

02 · A Stain Whose Pigment Drifts to the Rim as It Dries, Leaving a Dark Ring — Measured area 27.987% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIM_FLOWDRY_TIMERIM_SMOOTH
                if (dist[i] <= 1) {
                    continue;
                }
                const wl = downhill(dist, mask, i, l);
                const wr = downhill(dist, mask, i, r);
                const wu = downhill(dist, mask, i, u);
                const wd = downhill(dist, mask, i, d);
                const sum = wl + wr + wu + wd;
                if (sum <= 0) {
                    continue;
                }
                const move = f[i] * frac;
                flowTmp[i] -= move;
                flowTmp[l] += move * wl / sum;
                flowTmp[r] += move * wr / sum;
                flowTmp[u] += move * wu / sum;
                flowTmp[d] += move * wd / sum;

03A Bleed That Spreads Only Through Wet Cells and Mixes Where Two Colors Meet

Two clear-water patches are brushed on from the left at 0.2 s, and from 0.45 s sixteen touches alternate coral on the left and sky blue on the right every 0.16 s, drifting toward the middle, while each step runs wetDiffuse 14 times per pigment at DIFFUSE_RATE 0.18. The key line is const g = Math.min(wi, water[j]);: the exchange with the right or lower neighbor is scaled by the smaller of the two water values, so a dry neighbor receives nothing and the two colors can only meet in cells that are wet on both sides, a good fit for the cover of a picture book introduction page. Cells holding more than 0.05 of both pigments go from 0 at 1.5 s to 579 at 3.5 s and 760 at the end of painting, and the pigment that leaked into cells that started dry stays at 0.

A Bleed That Spreads Only Through Wet Cells and Mixes Where Two Colors Meet
Open in a new tab

03 · A Bleed That Spreads Only Through Wet Cells and Mixes Where Two Colors Meet — Measured area 29.567% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

DIFFUSE_RATEDIFFUSE_SUBSTEPSEVAPORATION
                const i = y * gw + x;
                const wi = water[i];
                if (wi <= 0) {
                    continue;
                }
                if (x + 1 < gw) {
                    const j = i + 1;
                    const g = Math.min(wi, water[j]);
                    if (g > 0) {
                        const flow = k * g * (before[j] - before[i]);
                        p[i] += flow;
                        p[j] -= flow;
                    }
                }
                if (y + 1 < gh) {
                    const j = i + gw;
                    const g = Math.min(wi, water[j]);
                    if (g > 0) {
                        const flow = k * g * (before[j] - before[i]);
                        p[i] += flow;
                        p[j] -= flow;
                    }
                }

04A Paper Surface Where Pigment Grains Settle into the Low Spots

Bands of butter, sky blue and sage are swept left to right in 0.9 s each, and after a band lands its settling strength rises over SETTLE_TIME 1.3 s toward GRANULATION 0.8, which at full strength multiplies density by 1.8 in the troughs of the paper (height 0) and by 0.2 on the crests (height 1). The gran line inside the compose loop below applies that per pixel as Math.max(0, 1 + gran[k] * (1 - 2 * h)), and with PAPER_RELIEF 1.8 shading the bumps it gives a handmade stationery product page a background that reads as paper instead of a flat fill. Along the middle row of the sky blue band the mean brightness only moves from 178.5 to 179.5, yet its standard deviation grows from 7.07 right after painting to 15.17 once the grains have settled, 2.1 times the contrast.

A Paper Surface Where Pigment Grains Settle into the Low Spots
Open in a new tab

04 · A Paper Surface Where Pigment Grains Settle into the Low Spots — Measured area 49.166% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRANULATIONSETTLE_TIMEPAPER_RELIEF
                const h = PAPER_HEIGHT[p];
                let lr = 0;
                let lg = 0;
                let lb = 0;
                for (let k = 0; k < n; k += 1) {
                    const f = fields[k];
                    let d = (f[i00] * (1 - tx) + f[i10] * tx) * (1 - ty) + (f[i01] * (1 - tx) + f[i11] * tx) * ty;
                    if (d <= 0) {
                        continue;
                    }
                    if (gran[k] !== 0) {
                        d *= Math.max(0, 1 + gran[k] * (1 - 2 * h));
                    }
                    const pk = pigs[k];
                    lr += pk.r * d;
                    lg += pk.g * d;
                    lb += pk.b * d;
                }

05A Dry Brush Stroke That Splits into Bristle Lines as It Speeds Up

Four strokes in indigo, coral, sage and lavender follow cubic curves bent by two control points, their position eased as progress raised to STROKE_EASE (1.8) so they accelerate toward the end, and the update below drains each stroke's water by (DRY_BASE 0.22 + SPEED_DRAIN 0.0017 × speed in px/s) × dt on every step. Each of the 16 bristles carries its own threshold between 0.04 and 0.62, and wherever the remaining water drops below that threshold, which noise nudges by up to ±0.08, the bristle skips its dot at that spot, so the tail of the stroke frays apart, which works as a hand-drawn underline under a cafe menu heading. The first bristle drops out 37 to 48% of the way along the four curves, and the two strokes whose final speed passes 740 px/s (741.5 and 766.3) lose all 16 bristles before they finish.

A Dry Brush Stroke That Splits into Bristle Lines as It Speeds Up
Open in a new tab

05 · A Dry Brush Stroke That Splits into Bristle Lines as It Speeds Up — Measured area 20.835% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

BRISTLE_COUNTSPEED_DRAINSTROKE_EASE
    function update(dt, t) {
        for (let k = 0; k < STROKES.length; k += 1) {
            const s = STROKES[k];
            if (t < s.start || strokeU[k] >= 1) {
                continue;
            }
            const u = Math.pow(phaseOf(t, s.start, s.duration), STROKE_EASE);
            const speed = (u - strokeU[k]) * strokeLen[k] / dt;
            strokeWater[k] = Math.max(0, strokeWater[k] - (DRY_BASE + SPEED_DRAIN * speed) * dt);
            const w = strokeWater[k];
            brushSegment(strokeFields[k], OW, OH, s.curve, strokeU[k], u, w, bristleSets[k], BRUSH_HALF, lerp(DOT_DRY, DOT_WET, clamp(w, 0, 1)), BRISTLE_INK * (0.5 + 0.5 * w), BRISTLE_WOBBLE);
            strokeU[k] = u;
        }
    }

06An Image Watercolor Filter That Turns a Landscape into Paint with Stepped Brightness and Wavering Edges

The source is a landscape of sky, sun, hills, a house and a tree computed pixel by pixel on the same page, and from 0.2 s a sweep replaces it left to right over 3.0 s with a painted copy built once in four stages: wavering edges, stepped brightness, darker outlines where neighbors differ, and paper grain. The excerpt holds the first two stages, moving the read position by up to ±EDGE_JITTER 3.2 pixels and averaging it with four neighbors, then rounding brightness to COLOR_LEVELS 5 steps and scaling all three channels by one ratio so the hue survives, a treatment that suits the featured image of a travel photo blog. The count of distinct 8-bit colors goes from 995 in the source to 858 after stepping and wobbling and up to 9,764 once outlines and grain are in, because the grain shading differs per pixel, and 3.0% of pixels end up more than 15% darker from the outline stage.

An Image Watercolor Filter That Turns a Landscape into Paint with Stepped Brightness and Wavering Edges
Open in a new tab

06 · An Image Watercolor Filter That Turns a Landscape into Paint with Stepped Brightness and Wavering Edges — Measured area 51.997% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

COLOR_LEVELSEDGE_JITTEREDGE_DARKEN
            for (let x = 0; x < OW; x += 1) {
                // ① Edge wobble — shift the read position by noise so the edges of color areas turn ragged like brush marks
                const sx = clamp(Math.round(x + (noise2(x / 7, y / 7, SEED + 11) - 0.5) * 2 * EDGE_JITTER), 0, OW - 1);
                const sy = clamp(Math.round(y + (noise2(x / 7 + 40, y / 7, SEED + 12) - 0.5) * 2 * EDGE_JITTER), 0, OH - 1);
                const o = (y * OW + x) * 3;
                // Smooth out small specks — read the average of the shifted spot and its four neighbors
                let r = 0;
                let g = 0;
                let b = 0;
                for (let q = 0; q < 5; q += 1) {
                    const qx = clamp(sx + (q === 1 ? -1 : q === 2 ? 1 : 0), 0, OW - 1);
                    const qy = clamp(sy + (q === 3 ? -1 : q === 4 ? 1 : 0), 0, OH - 1);
                    const s = (qy * OW + qx) * 3;
                    r += photo[s] * 0.2;
                    g += photo[s + 1] * 0.2;
                    b += photo[s + 2] * 0.2;
                }
                // ② Fewer color steps — round brightness to COLOR_LEVELS steps and multiply all three channels by the same ratio to keep the hue, then pull toward the paper color by PAPER_MIX
                const l = r * 0.299 + g * 0.587 + b * 0.114;
                const ratio = Math.round(clamp(l, 0, 1) * top) / top / Math.max(l, 0.02);
                stepped[o] = lerp(clamp(r * ratio, 0, 1), pr, PAPER_MIX);
                stepped[o + 1] = lerp(clamp(g * ratio, 0, 1), pg, PAPER_MIX);
                stepped[o + 2] = lerp(clamp(b * ratio, 0, 1), pb, PAPER_MIX);
            }

07A Bloom Where a Clear Drop Lands on Wet Color and Pushes It into Petal-Shaped Rims

A lavender-to-pink wash sweeps from the top left toward the bottom right in 0.5 s, and six clear drops with radii of 30 to 50 px land on it between 0.75 s and 2.2 s while it is still wet, each opening as 1 − e^(−elapsed / BLOOM_GROW 0.32) with a radius per angle shaped by PETAL_NOISE 0.4. Inside a drop the excerpt refills the water to 1, and only cells in the outer band share of the radius send a frac share of their pigment to the neighbor farther from the drop's center, skipping any cell drier than WET_MIN 0.12, which gives a spring seasonal sale banner its petal-shaped edges. When painting ends, the rim band of each of the six blooms holds 3.40 to 3.94 times the density of its inside, and the pigment total reads 7,272.94 before and after.

A Bloom Where a Clear Drop Lands on Wet Color and Pushes It into Petal-Shaped Rims
Open in a new tab

07 · A Bloom Where a Clear Drop Lands on Wet Color and Pushes It into Petal-Shaped Rims — Measured area 23.299% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

PETAL_NOISEBLOOM_PUSHBLOOM_GROW
                const i = y * gw + x;
                if (water[i] < wetMin) {
                    continue;
                }
                const dx = x + 0.5 - cx;
                const dy = y + 0.5 - cy;
                const dist = Math.hypot(dx, dy);
                const rr = bloomRadius(d, grow, Math.atan2(dy, dx), petal) * k;
                if (dist >= rr) {
                    continue;
                }
                // Refill the inside of the drop with clear water. Pushing happens only in the front band (the outer band share of the radius), so pale pigment stays in the middle
                water[i] = 1;
                if (dist < rr * (1 - band)) {
                    continue;
                }
                const j = (y + Math.round(dy / dist)) * gw + (x + Math.round(dx / dist));
                if (water[j] < wetMin) {
                    continue;
                }
                const move = field[i] * frac;
                delta[i] -= move;
                delta[j] += move;

08Glazed Color Mixing That Filters Light by Multiplying Transparent Layers

Two 240 px boards share one coverage map: the layers GLAZE_BLUE, GLAZE_YELLOW and GLAZE_ROSE start at 0.2, 1.3 and 2.4 s, each built from 8 jittered sheets and revealed by a brush front that crosses in LAYER_TIME 1.0 s, with an amount of GLAZE_ALPHA 0.35 × coverage. The left board, labeled "Multiply reflectance", runs glazeAbsorb below and multiplies each channel by 1 − amount × (1 − reflectance), while the right board, "Plain alpha blend", pulls the current color toward the layer color, which is the side-by-side an art class color-mixing handout needs. On the paper color [250,245,232] at full coverage, the yellow layer over the blue one (GLAZE_YELLOW over GLAZE_BLUE) comes out [180,187,153] at a hue of 72° by absorption but [206,203,159] at 56° by alpha blending, and swapping the order leaves the absorption result unchanged while the alpha blend turns into [181,193,183].

Glazed Color Mixing That Filters Light by Multiplying Transparent Layers
Open in a new tab

08 · Glazed Color Mixing That Filters Light by Multiplying Transparent Layers — Measured area 28.826% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

GLAZE_ALPHAGLAZE_SHEETSLAYER_TIME
    function glazeAbsorb(base, layers, amounts) {
        let r = base[0];
        let g = base[1];
        let b = base[2];
        for (let k = 0; k < layers.length; k += 1) {
            const a = amounts[k];
            r *= 1 - a * (1 - layers[k][0] / 255);
            g *= 1 - a * (1 - layers[k][1] / 255);
            b *= 1 - a * (1 - layers[k][2] / 255);
        }
        mixR = r;
        mixG = g;
        mixB = b;
    }

09A Watercolor Landscape Hero That Paints Itself from Sky Wash to Dry-Brush Hills

The hero paints in stages pinned to clock times, a wet sky wash at 0.2 s, glazed far ridges at 1.4 s, dry-brush hills at 2.5 s and two drop blooms at 3.5 and 3.85 s, finishing at 4.6 s, while a status line at the top left names the current stage, from "Wet sky bleed" to "Finished". The excerpt shows one step reusing the earlier scenes' functions: pushBloom opens the blooms on the still-wet sky, wetDiffuse runs SKY_SUBSTEPS (8) times per sky pigment, and deformPolygon with fillPolygon adds one RIDGE_ALPHA 0.075 sheet at a time up to 9 per ridge, which gives a gallery website a hero that visibly paints itself. At bloom time 7,776 sky cells are still wet with an average water of 0.708, the three hill strokes run out of water entirely, and the sky's pigment total stays at 6,591.07 through every bleed and bloom.

A Watercolor Landscape Hero That Paints Itself from Sky Wash to Dry-Brush Hills
Open in a new tab

09 · A Watercolor Landscape Hero That Paints Itself from Sky Wash to Dry-Brush Hills — Measured area 54.138% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

SKY_DIFFUSERIDGE_ALPHAHERO_PETAL
        // ⑤ Drop bloom — drop clear water on the still-wet sky to push the pigment into petal rims
        for (let k = 0; k < HERO_DROPS.length; k += 1) {
            const d = HERO_DROPS[k];
            if (t < d.at) {
                continue;
            }
            const grow = 1 - Math.exp(-(t - d.at) / 0.3);
            for (let f = 0; f < 3; f += 1) {
                pushBloom(skyFields[f], skyWater, GW, GH, d, grow, HERO_PETAL, HERO_PUSH, 0.4, 0.1, skyTmp);
            }
        }
        for (let s = 0; s < SKY_SUBSTEPS; s += 1) {
            for (let f = 0; f < 3; f += 1) {
                wetDiffuse(skyFields[f], skyWater, GW, GH, SKY_DIFFUSE, skyTmp);
            }
        }
        // ② Glazed far mountains — re-jitter the ridge polygon for each sheet and stack them faintly
        for (let k = 0; k < RIDGES.length; k += 1) {
            const want = Math.floor(RIDGE_SHEETS * phaseOf(t, RIDGES[k].start, 0.8));
            while (ridgeSheets[k] < want) {
                const sheet = deformPolygon(ridgeShapes[k], 4, RIDGE_JITTER, makeRng(SEED * 10 + k * 100 + ridgeSheets[k]));
                fillPolygon(RIDGES[k].field, GW, GH, sheet, RIDGE_ALPHA);
                ridgeSheets[k] += 1;
            }
        }

What Does a Watercolor Animation Effect Need to Compute on a Web Page?

Painting the color in is the easy half. What makes a stain look wet is where the color goes after it lands, and that depends on quantities a single fill call does not hold.

Water, pigment and the height of the paper

Every scene tracks three things per cell: how much water sits there, how much pigment, and how high the paper is at that spot. Water decides where pigment may travel (03, 07), the shrinking water level decides when pigment heads for the rim (02), and the height map built from two octaves of value noise decides where grains collect (04) and how the bumps catch light from the top left. None of the nine calls Math.random; each draws from a seeded generator, so the result behaves like generative art watercolor in the strict sense, where one seed number reproduces one painting stroke for stroke on every cycle.

Colors multiply instead of adding

compose stores each pigment as the light it sends back after one layer on white paper, kept per channel as a log ratio, and turns the summed density back into light as paper × ratio^density. Layers therefore deepen by multiplication, the way 08's left board does it, and a heavy spot darkens toward the pigment's own hue instead of turning gray.

How this set differs from its neighbors

The marble effect post pushes and combs a film of ink floating on water, so nothing soaks into paper there. The fluid grid post solves a velocity field for smoke and dye, while 03 has no velocity at all, only diffusion between cells that hold water. The value noise behind the paper height shares its roots with the noise terrain post, and where the pixel art converter groups pixels into blocks, 06 only cuts brightness into steps and keeps the hue. Someone after a watercolor effect online, to run one picture through and download the result, will find 06 the closest scene here, with the difference that the conversion runs inside your own page.

Jittered Polygons in Layers, Pigment Diffusing Through Wet Cells, Rims from Distance as It Dries — the Math Behind One Drop of Paint

Heavy math on 160 columns, color on 320

Water, diffusion and distance run on a GW×GH grid of 160×100, one cell per 3 px of board, while color composition and paper grain use a 320×200 pixel grid. The result lands in a Float32 buffer, present() copies it into one ImageData made once with createImageData, puts it on a hidden canvas and draws that onto the 480×300 board with imageSmoothingQuality set to 'high'. The device pixel ratio is capped at 2, and no frame creates a new array or a new ImageData.

A fixed 1/30 s step and a head start for the demo

The gap between two requestAnimationFrame timestamps is capped at DT_MAX 0.05 s and spent in STEP slices of 1/30 s, so the same seed gives the same picture at any frame rate. Items update only while the clock is inside PAINT_TIME; past CYCLE the clock returns to 0 and reset() paints again. The auto demo jumps ahead to DEMO_START (0.9 to 1.4 s) before it starts drawing, which is why the first view is already a stain in progress and not a blank sheet.

Distance to the rim from two sweeps

02 needs the distance from each wet cell to the nearest dry one. distanceToEdge gets it with one pass top to bottom and one bottom to top, each cell taking the smallest neighbor value plus 1 for a straight step or 1.414 for a diagonal, and softenDistance then blends neighbor averages twice to flatten the stair steps.

Where the Paint Breaks: Blocked Pixel Reads from Another Domain's Photo, Muddy 8-Bit Color After Hundreds of Layers and a Slow Diffusion Grid on a Wide Screen

Reading pixels from someone else's photo

06's loadPhoto puts the computed landscape on a hidden canvas and reads it back with getImageData, and it wraps that read in try and catch, keeping the precomputed source when the read fails. The demo draws its source on the same page, so the canvas is not tainted. Draw a photo from another domain without permission and the canvas becomes tainted, at which point this exact call throws a security error; a real photo needs the image's crossOrigin attribute together with an allowing response from its server (MDN on cross-origin images). To change that part yourself, open the 06 folder of the zip with the password 5tkwg3wj and go to loadPhoto.

Covering a pale color hundreds of times

Canvas pixels are whole numbers from 0 to 255, so a blend rounds after every coat. Once the remaining gap × alpha falls under 0.5, the value stops moving and parks 0.5 / alpha steps short of the target. Coating coral [240,148,132] over the paper color [250,245,232] 400 times at alpha 0.042 ends at [250,159,143] in 8 bits against [240,148,132] in Float32, and the red channel does not move once, because its gap of 10 × 0.042 is only 0.42. At alpha 0.01 the 8-bit result stalls at [250,198,182], so paler coats stop farther away, and because each channel stalls at a different place the hue drifts too. 01 sidesteps this by adding density in a Float32 grid and capping each blot at LAYERS_PER_BLOT 40 layers.

Running diffusion at screen resolution

Measured at development time in Node.js on the development machine, one wetDiffuse pass took 0.081 ms on the 160×100 grid (16,000 cells) and 3.01 ms on 960×600 (480×300 at a pixel ratio of 2, 576,000 cells, 36 times as many). 03 calls it 28 times per step, 14 substeps for each of two pigments, which adds up to 2.26 ms against 84.2 ms, and the larger figure is 2.5 times the 33 ms budget of a 1/30 s step. Timings differ by device; the 36× ratio is what matters, and it is why diffusion runs on 160 columns and only color composition on 320.

Less Jitter, Faster Diffusion, a Denser Glaze — One Value That Changes How Wet the Painting Looks

Each row changes a single constant in the source and reruns the same functions.

Variant Value changed What changes
Less polygon jitter 01 BLOT_JITTER 0.45 → 0.2 (line 343) The average turn per vertex drops from 0.755 to 0.37 radians, the 640-point outline shrinks from 873 to 573 px and the 10%→90% rim narrows from 4.5 to 1.5 px while the center stays at 1.68, so the blot looks closer to cut paper than to paint
Faster diffusion 03 DIFFUSE_RATE 0.18 → 0.3 (line 312) Cells holding more than 0.05 of both pigments rise from 760 to 1,096 at the end of painting (+44%) and from 579 to 799 at 3.5 s, so the violet band widens, and leakage into dry cells stays 0 at both values
A denser glaze 08 GLAZE_ALPHA 0.35 → 0.6 (line 337) GLAZE_YELLOW over GLAZE_BLUE by absorption goes from [180,187,153] to [130,149,101], its hue from 72° to 84° and its brightness from 181.0 to 137.8, the alpha blend moves to [204,191,108] at 52°, and two GLAZE_BLUE layers alone fall from 159.8 to 111.5 in brightness

Readable Even When Still: a Finished Painting Under Reduced Motion, a Pause Button and a Scene Description for Accessibility

The finished painting under reduced motion

All nine scenes listen to matchMedia('(prefers-reduced-motion: reduce)'), including its change event, and when the setting is on they call showFinished(): the same seed is computed straight through PAINT_TIME and shown as one finished picture, with no loop and no wash (MDN on prefers-reduced-motion). The picture a visitor gets is the one the animated version arrives at.

The pause button and the keyboard

Each scene has a pill-shaped "Pause" button at the bottom right, a native button with type="button" and aria-pressed, so Enter and Space work without extra code; when pressed it flips from ink on cream to cream on ink. Keyboard focus shows a 3 px ink outline through :focus-visible. Drawing also stops while the tab is hidden and picks up again when it returns, unless the scene is paused or motion is reduced.

Scene descriptions and the status line in 09

Each canvas carries role="img" and an aria-label sentence that describes the picture, such as 01's "Coral, lavender, butter and sky blue blots build up one faint layer at a time, rich in the middle and soft at the edges." 09 also has a status line naming the current stage, and its aria-live is off during the auto demo and switches to polite after the first input, so a screen reader is not interrupted by a demo nobody started.

Questions to Answer Before Painting a Watercolor Background Animation Yourself

How do I swap the paint for my brand colors?

The palette sits near the top of each script as RGB arrays, PAPER [250,245,232], SKY [146,192,230], CORAL [240,148,132] and the rest, and 08 has its own GLAZE_BLUE, GLAZE_YELLOW and GLAZE_ROSE. Each shared palette value is the color a single layer gives on white paper, and compose turns it into a log ratio, so darker values deepen much faster when layers pile up; start one step lighter than your brand color.

Will a watercolor animation effect stutter on a phone?

The heavy math runs on 160×100 cells, the pixel ratio is capped at 2, one ImageData is reused, and drawing stops while the tab is hidden. If a device falls behind, the 0.05 s cap on each frame's time means the painting likely slows down rather than skipping steps, and once the 3.6 to 4.6 s painting phase is over the scene is mostly holding a still picture.

Can I put my own photo into 06?

Yes, as long as the image can be read back. Draw it to the 320×200 source canvas in loadPhoto; if it comes from another domain, the image needs crossOrigin and the server has to allow it, otherwise getImageData throws and the catch keeps the computed landscape instead.

Enter the archive password

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