The part of my blueprint page people liked most was not the lines. It was the moment the drawing soaks in from the top, in blots, like ink into paper. So I took that one effect, dropped the scan line, and built a whole gallery around it: the world's best views as oil paintings, from Manhattan to Machu Picchu, each one soaking in over the last. It is live at avivashishta.com/lab/wet-paint.
A new painting never fades or slides in. It lands at the top as a soft, dark wash of its own colours, a few drips run ahead down the canvas, and the sharp painting dries in behind the wash. The old painting stays underneath until it is covered.
The short version: Codex paints each view from the same prompt with one line changed. The page is one canvas. When the painting changes, the canvas keeps a copy of what is on screen and paints the new painting over it twice, through two masks cut from the same noise field: first a blurred, darker copy (the wet wash), then the sharp painting a little behind it. A few columns of the field are pulled ahead, and those are the drips.
Step 1: the paintings (Codex)
A gallery only looks like a gallery if every piece is clearly by the same hand. So every painting uses one prompt, and only the subject changes. I asked for an oil painting photographed flat, edge to edge, so there is no frame or wall to crop out, and for a calm upper third, because that is where the paint lands first.
A large contemporary oil painting on canvas, photographed flat and straight on so the painting fills the entire frame edge to edge: no frame, no wall, no border, no text, no signature. Loose, confident, expressive brushwork with thick impasto in the highlights, visible palette-knife strokes, broken colour, rich saturated pigment and luminous light, like a modern plein-air master painting. A strong simple composition with a clear focal point and generous sky or negative space in the upper third. No people in close-up, small distant figures at most. Landscape 3:2, 1536x1024.
Subject: Santorini at sunset: white cube houses and blue domes stacked down the caldera cliff, windmills on the ridge, the Aegean far below turning violet, the sky molten orange and rose.
The views: Santorini, Manhattan at dusk, Kyoto in autumn, Torres del Paine, Paris from the Seine, the aurora over Kirkjufell, Tokyo in the rain, the balloons over Cappadocia, Machu Picchu, the Amalfi Coast and a Norwegian fjord. Ask for a time of day and a light in every subject line (sunset, first light, neon in the rain, morning cloud). Paintings with strong light make the wet wash look far better, because the colours are what you see first.
With the Codex CLI, put the prompt in a file and pass it on stdin, one job at a time. A long prompt as an argument kills the job with an empty log.
codex exec --skip-git-repo-check -C ./raw - < santorini.prompt
Then bun scripts/prepare-wet-paint.mjs ./raw makes the WebPs and writes a short hash of each into a JSON file. The page asks for santorini.webp?v=<hash>, so a repainted view never meets an old cached copy, and it only shows paintings that are in that file, so one that is still generating can't stall the carousel.
Step 2: the field, now with drips
Like the blueprint, each painting gets a field of numbers, one per pixel of a small grid, that says when that pixel's turn comes: mostly top to bottom, broken up by blotchy noise at three sizes. Paint adds one thing ink doesn't have. It runs. So a few narrow columns are pulled ahead of the rest:
// One value per column: mostly zero, with a few sharp peaks. Raising noise to the 6th power is what makes them rare.
drips[x] = Math.pow(noise(x / 2.6, 0.5, seed), 6) * 0.9 + Math.pow(noise(x / 9, 3.5, seed), 3) * 0.25;
const blot = noise(x / 34, y / 34, seed) * 0.55 + noise(x / 14, y / 14, seed) * 0.3 + noise(x / 5, y / 5, seed) * 0.15;
field[i] = (y / h) * 0.62 + blot * 0.3 + grain * 0.04 - drips[x] * 0.16;
A column with a high drip value reaches every row sooner, so it shows up as a thin streak running down ahead of the edge. Each painting uses its own seed, so no two soak in the same way.
Step 3: the wet wash, then the dry painting
This is what makes it feel like paint instead of a dissolve. The front edge is not the painting. It is a blurred, darker copy of the painting, and the real one arrives a moment later behind it, so you watch the colours land and then the detail dry in.
The blurred copy is made once per painting. Shrinking an image to a few dozen pixels and growing it back is a blur that works in every browser, and drawing it over itself with multiply makes it darker and richer, like pigment before it dries:
// Down in steps (one big jump shimmers), then up in two (one big jump leaves soft diamonds).
for (const width of [384, 120, 40, 120, 480]) { /* draw src into a canvas `width` wide; src = that canvas */ }
o.globalCompositeOperation = "multiply";
o.globalAlpha = 0.45;
o.drawImage(out, 0, 0); // the wash, multiplied by itself
Every frame, the same field cuts two masks: one for the wash at the front, and one for the sharp painting a little behind it (WASH_LEAD, 0.13 of the field):
const t = progress * (1 + WASH_LEAD + FRONT + 0.16) - 0.16; // where the front is
through(wash, t); // the wet wash, at the front
through(painting, t - WASH_LEAD); // the dry painting, behind it
through is the blueprint's trick: write the mask's alpha (pixels whose turn has come are opaque, those just behind the front fade in), stretch it to full size so it blurs softly, and draw the painting into it with source-in.
Step 4: one canvas, and the last frame as the base
A carousel usually has two images, one leaving and one arriving. This one has a single canvas and a copy of its last frame. When the painting changes, the page copies whatever is on screen into the base, and every frame draws the base first, then the new painting through its masks:
show = (i) => {
bctx.drawImage(canvas, 0, 0); // keep exactly what is on screen
cur = { i, start: -1 }; // the new painting starts soaking in over it
};
That one choice handles the awkward cases for free. Click three thumbnails quickly and each painting soaks in over the half-soaked one before, with no jump. The very first painting has no base, so it soaks in over the page itself, a sheet of paper with "Wet paint" on it.
The small things
- Timing is an ease-out. My first version used the blueprint's ease-in-out, and after a click nothing seemed to happen for a second. Paint lands at once and slows as it spreads, so the curve does too:
1 - (1 - t) ** 1.9over 4.2 seconds. - The painting keeps moving. While it is up, it zooms out slowly from 107% and shifts a little toward your cursor, so a still painting never feels like a still image.
- The label is paper on a soft shade, a gradient at the foot of the screen, so it reads over a white Greek cliff and an Icelandic night alike.
- The thumbnail bar is the clock. When its CSS animation ends, the gallery moves on. Hovering the thumbnails pauses it. Arrow keys and a swipe work too.
- Reduced motion gets each painting at once, with no soak and no drift.
Build your own
Build a full-screen image gallery (React, one 2D canvas, no libraries) where each new image soaks in over the previous one from the top, like wet paint. Draw each image like object-fit: cover, slowly zooming out from 107% and drifting slightly toward the cursor.
For each image, make a field on a small grid (about 320 wide, matching the screen's aspect) that says when each pixel's turn comes: y / height * 0.62, plus value noise at three scales (about 34, 14 and 5 cells) * 0.3, plus a little random grain, minus a per-column drip value (value noise in x only, raised to the 6th power) * 0.16, so a few thin columns run ahead.
When the image changes, copy the canvas into a "base" canvas. Every frame, draw the base, then draw the new image twice through masks cut from the field: first a blurred copy (shrink it to about 40px wide in steps, grow it back to 480 in two steps, then multiply it by itself at 0.45 alpha so it reads darker and wetter) at the front, then the sharp image 0.13 behind it. A mask pixel's alpha is (t - field) / 0.07, clamped; put it in a small canvas, stretch it to full size with smoothing, then draw the image into it with source-in. Advance t with an ease-out over about 4 seconds. Thumbnails with a CSS progress bar on the active one move to the next image when the bar ends; pause on hover. Respect prefers-reduced-motion.