---
title: "The Tank: A Skincare Store Under Water"
description: "How I built a skincare store where the products drift in a tank of water: stir it with the pointer, click a bottle and it rises through the bubbles with its ingredients floating up beside it. Codex photos, a 2D canvas and one WebGL water pass."
date: "2026-11-13"
type: "build"
tags: ["webgl", "shader", "canvas", "codex", "image generation", "ecommerce", "creative coding", "nextjs"]
output: "/lab/tank"
---

Skincare ads are always wet: a serum bottle half under water, light rippling over it. The store pages are always dry. I wanted the store to be the ad, so the whole page is a tank of water with the products drifting in it. [Dive in at avivashishta.com/lab/tank](/lab/tank).

Move the pointer and the water ripples and the bottles near it get pushed along. Click one and it rises to just under the surface through a stream of bubbles, its ingredients float up beside it, and the water takes on its colour.

<div class="highlight"><p><strong>The short version:</strong> Codex makes one photo of an empty tank of water and one product shot of each bottle, which macOS Vision cuts out. Every frame, a 2D canvas draws the tank, the bottles where a little spring physics has put them, and the bubbles. Then one WebGL pass takes that picture and makes it water: it bends it with a slow wobble and with rings where the pointer went, and lays moving caustic light over the top.</p></div>

## Step 1: the tank and the bottles (Codex)

The tank has to be empty, so the products can be anywhere in it:

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>Looking into a large clear glass aquarium tank full of clean pale aqua water, empty, no fish, no plants, a smooth pale sand-coloured floor, soft rays of sunlight coming down from the surface at the top with gentle caustic light patterns on the floor and back, a few tiny bubbles, calm, bright, airy, high-end skincare advertising photography, photoreal. No text, no logos, no products. Landscape 3:2, 1536x1024.</p>
  </div>
</div>

The bottles are ordinary product shots on white with blank labels ("any label left completely blank" keeps Codex from inventing a brand), cut out with macOS Vision:

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>A luxury skincare product photo of a frosted glass serum bottle with a white rubber dropper top. Minimal and unbranded, any label left completely blank, no text, no logos. Standing upright, front view, centered with generous margin, on a pure seamless white background (#FFFFFF), soft studio light, a very faint contact shadow only, photoreal, crisp. Square 1:1, 1024x1024.</p>
  </div>
</div>

```bash
bun scripts/prepare-tank.mjs ./raw
```

## Step 2: draw it dry

The scene is drawn first as if there were no water: the tank photo covering the screen, the bottles back to front, the bubbles on top. Each bottle is a small spring pulled back toward a home spot that itself drifts in a slow current, with a little rotation, so they bob and turn. The pointer pushes bottles near it with its own speed, and the chosen bottle's home moves up toward the surface. Bubbles are just circles with a highlight; a rising bottle sheds a stream of them.

## Step 3: then make it wet

That picture is uploaded as a texture every frame, and one fragment shader draws it with water in front. Three things do the work:

```glsl
// A slow wobble everywhere,
vec2 d = vec2(n(uv * 6. + uTime * .25) - .5, n(uv * 6. - uTime * .2 + 9.) - .5) * .006;
// rings where the pointer stirred it,
float ring = sin((dist - front) * 60.) * exp(-pow((dist - front) * 9., 2.)) * exp(-r.z * 1.6) * r.w;
d += v / max(dist, 1e-3) / a * ring * .012;
// and light from above.
col += vec3(1., 1., .96) * caustic(uv * a * 3.) * (.1 + depth * .14);
```

The ripples are six slots of (position, age, strength) that the pointer fills as it moves fast enough; each ring travels out and dies away. The caustics are three layers of warped sine grids raised to a high power, so only their crossings light up: the bright net that sun throws through moving water. Deeper is darker and a little bluer, and when a product is chosen the whole tank is tinted toward its colour.

## The small things

- **The ingredients rise like bubbles** into the product card, one after another.
- **The others sink a little and fade** while one is up, so it is clearly the one.
- **A plain list at the bottom** for anyone who would rather not fish for a bottle.

## Build your own

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>Build an underwater product page (React, canvas 2D plus one WebGL pass, no libraries). Assets: a photo of an empty water tank and cut-out product photos. Each frame, draw into an offscreen 2D canvas: the tank photo cover-fit, the products (each a damped spring toward a home spot that drifts slowly, pushed by the pointer's velocity when it is near), and rising bubbles. Upload that canvas as a texture and draw it full screen with a fragment shader that offsets the lookup by low-frequency noise over time and by up to six expanding ripple rings from where the pointer moved, adds caustics (warped sine grids raised to the 8th power) stronger near the top, darkens with depth and multiplies by a tint.</p>
<p>Clicking a product moves its home up and to the left, scales it up, emits bubbles behind it as it rises, dims the others and tints the water its colour. Show its name, ingredients (animated in like bubbles), price and Add to bag.</p>
  </div>
</div>
