---
title: "Try On: A Bag Store Where One Model Wears Every Bag"
description: "How I built a luxury bag store where you pick a bag, it flies onto the model and the new look ripples out from where she wears it. Codex for the model and the bags, macOS Vision for the cut-outs, one WebGL shader. Prompts and code inside."
date: "2026-11-12"
type: "build"
tags: ["webgl", "shader", "codex", "image generation", "ecommerce", "creative coding", "nextjs"]
output: "/lab/try-on"
---

Product pages always show the bag on a white background, and then, if you are lucky, one photo of someone wearing it. I wanted a store where the model is the page: you pick a bag and she is wearing it. [It is live at avivashishta.com/lab/try-on](/lab/try-on).

The bags ride a diagonal belt that passes behind her. Pick one (or let it play) and the bag lifts out of its card, arcs over and lands on the spot where she will carry it: the shoulder, the hand, the crook of the elbow. Then the new look spreads out from that spot over the old one, like a ripple through fabric, with a thin blue scan line that only lands on her.

<div class="highlight"><p><strong>The short version:</strong> Codex makes one photo of the model, one catalogue shot of each bag, and then the model carrying each bag, by editing her photo with the bag's photo attached. A script lines every look up with the first (Codex reframes a little on every edit), cuts her out with macOS Vision and works out where on her body the bag went. The page draws her with one WebGL shader that blends two looks through a noisy circle growing from that spot.</p></div>

## Step 1: one model (Codex)

Everything depends on this photo, because every other look is an edit of it. So the prompt is mostly about making her easy to edit: facing the camera, arms down and a little away from the body (so a bag can go on either side), no accessories, cropped at mid thigh, on pure white.

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>A high-end luxury fashion e-commerce studio photo. A strikingly attractive blonde woman fashion model in her mid twenties, long sleek honey-blonde hair worn loose behind the shoulders, sun-kissed skin, light natural glam makeup, confident cool expression, looking into the lens. She stands straight, facing the camera, arms hanging relaxed at her sides, slightly away from the body so the hands are clearly visible. She wears a fitted black ribbed tank top and high-waisted cream tailored wide-leg trousers. No bag, no jewellery, no watch, no hat, no glasses. Framed from just above the top of the head down to the mid thigh, centered, the head near the top of the frame with a little space above. Pure seamless white background (#FFFFFF), soft even front studio lighting, very soft shadow, no floor line, photoreal, sharp, editorial luxury catalogue quality. Portrait 2:3, 1024x1536.</p>
  </div>
</div>

Black on top and cream below is on purpose: every bag colour, from white to oxblood to silver, has something to stand out against.

## Step 2: the bags

Eight bags, one prompt, only the description changes. Ask for no logos and no text, or you will get something that looks like a real brand.

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>A luxury leather goods e-commerce catalogue photo of a black lambskin shoulder bag with soft pillowy padding in wide stitched channels, a flap front with no logo and a chunky polished gold chain strap. The bag alone, no logos, no branding, no text, floating, three-quarter view, centered with generous margin, on a pure seamless white background (#FFFFFF), soft studio light, a very faint soft contact shadow only, photoreal, crisp detail. No people, no props. Square 1:1, 1024x1024.</p>
  </div>
</div>

## Step 3: put the bag on her

This is the trick that makes the store believable: the try-on photo is not a new photo of a new woman. It is the first photo, edited, with the bag's own catalogue shot attached as a second image. So the bag she carries is the bag in the card.

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>The first attached image is a fashion catalogue photo of a model. The second attached image is a luxury bag: (the bag's description). Edit the first image so the same model is (how she carries it, e.g. holding the bag by its top handle in her left hand, arm relaxed, the bag hanging at her thigh). The bag must look exactly like the one in the second image (same shape, size, colour, hardware and material), at a realistic size on her.</p>
<p>Keep EVERYTHING else identical to the first image: the same woman and face, the same hair, the same pose (only the arm that carries the bag may move, as little as needed), the same black tank top and cream trousers, the same framing, crop, scale and position in the frame, the same camera angle, the same lighting and the same pure white background. Only add the bag. Photoreal. Portrait 2:3, 1024x1536.</p>
  </div>
</div>

Write how each bag is carried, not just which bag. A top-handle bag in the hand, a baguette under the arm, a tote in the crook of the elbow and a bucket bag crossbody make eight looks that feel like a real shoot instead of the same bag stuck on the same shoulder.

With the Codex CLI, put each prompt in a file and pass it on stdin, one job at a time. Put the images first: `-i` takes several files, so a `-` straight after it is read as a file name.

```bash
codex exec --skip-git-repo-check -i raw/model.png -i raw/p-tote.png -C . - < m-tote.prompt
```

## Step 4: line them up

"The same framing" is a request, not a promise. On a test run, one edit came back 2% smaller and 30 pixels lower. On the bag run it held to within half a percent and 5 pixels, but you can't know which you'll get, and on a page where one look replaces another in place, even 5 pixels is a visible twitch.

So the prep script finds, for each look, the scale and shift that put it back on top of the first photo. It compares the two photos in grey at 64 pixels wide, tries every scale from 0.8 to 1.2 and every shift, keeps the best, then searches again around it at 128 and 256 pixels wide. Each pixel's error is clamped, so the bag itself (which is meant to be different) can't drag the fit:

```js
const d = a[y * w + x] - b[by * w + bx];
sum += Math.min(d * d, 0.02); // a bag pixel counts no more than a slightly off one
```

Then every look is resampled into the frame of the most zoomed-in one. Going the other way would shrink some of them and leave her legs ending above the bottom of the frame.

## Step 5: cut her out (macOS Vision)

The cards pass behind her, so she needs a transparent background. Removing white by colour fails on a white catalogue photo. macOS has the same subject lifting as "Copy Subject" in Photos, and it is about 15 lines of Swift:

```swift
let request = VNGenerateForegroundInstanceMaskRequest()
try handler.perform([request])
let result = request.results!.first!
let cut = try result.generateMaskedImage(ofInstances: result.allInstances, from: handler, croppedToInstancesExtent: false)
```

It keeps the gaps between her arms and her body, so you see the cards through them. The script also finds where each bag went: the centre of the pixels that changed most between her bare photo and the photo with the bag. That point is where the bag flies to and where the new look starts.

```bash
bun scripts/prepare-try-on.mjs ./raw
```

## Step 6: the change

She is drawn by one WebGL shader with two looks loaded, the old one and the new one. A circle grows from the spot where the bag landed; inside it is the new look, outside the old. Three things make it feel like more than a wipe:

```glsl
// The front is roughed up by noise, so it reads as cloth, not a circle.
float e = dist + (fbm(vUv * vec2(4.5, 6.8) + uSeed) - .5) * .16 - uR;
float band = exp(-e * e / .0012) * uLive;
// The old look is pushed outward by the front and the new one pulled in behind it: a ripple.
vec2 push = dir * band * .022;
vec4 a = texture2D(uFrom, vUv - push);
vec4 b = texture2D(uTo, vUv + push * .5);
// The light only lands on her: weighted by alpha, so the scan wraps her outline.
float body = max(a.a, b.a);
col.rgb += body * (uTint * (band * .38 + trail * lines * .14) + vec3(core * .6));
```

Because the glow is multiplied by the cut-out's alpha, the blue line never shows on the background. It runs over her like a scanner tracing her outline, and over the bag as it appears.

## Step 7: the belt and the throw

The belt is sixteen cards (each bag twice, so it is always full) on a shallow diagonal through her hips, moved one slot at a time with an ease. Clicking a card sends the belt the short way round.

The throw is a copy of the bag's image, animated with the Web Animations API from the card's position to the spot on her body: up in an arc, a slight tilt, then shrinking and blurring into her. It uses `mix-blend-mode: multiply`, so the white of the product shot disappears over the page and over her. The shader starts the moment it lands.

## The small things

- **The loader is a disc, like my homepage's.** While her nine photos load, the sixteen bag cards deal into a tilted, spinning ring, with "Avi." top left and a rolling percentage that counts the real downloads. When they are in, the ring unrolls card by card into the belt, "Avi." flies into the header and she rises in.
- **The bag she has on gets its own card** on the right, "Now wearing", with its photo and price, swapped the moment the new look starts to spread.
- **It plays itself.** Every few seconds the next bag goes on. Clicking, hovering over the belt or pressing ← → takes over, and it waits nine seconds after you stop.
- **The buy box is real enough.** Name, price in rupees, colour, Add to bag (the count in the header bumps) and Next.
- **The headline sits behind her**, so her head overlaps "One model." like a magazine cover.
- **`?slow`** plays every move five times slower, which is how I tuned the ripple.
- **Reduced motion** skips the throw and gets a short fade.

## Build your own

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>Build a full-screen store page (React, WebGL, no libraries) where one model wears whichever product you pick. Assets: model.webp and m-&lt;item&gt;.webp (the same model wearing each item, 2:3, transparent background, all lined up in the same frame) and p-&lt;item&gt;.webp (product shots on white), plus for each item the point on the body where it is worn, as 0..1 of the frame.</p>
<p>Draw the model on a bottom-anchored canvas with a fragment shader that takes two looks, a point and a radius. Inside a circle round the point show the new look, outside the old one. Roughen the circle's edge with fbm value noise (amplitude about 0.16), and near the edge displace the old look outward and the new one inward by up to 0.02 for a ripple. Add a blue glow along the edge, a white core and faint scan lines just behind it, all multiplied by the max of the two looks' alpha so they only show on the model. Grow the radius from -0.06 to past the farthest corner over 1.5 s with an ease-out.</p>
<p>Behind the canvas, put the product cards on a shallow diagonal belt through her hips (each item twice so it is always full), moved one slot at a time. When an item is picked, slide the belt so it is at the centre, fly a copy of the card's image (multiply blend) in an arc to the body point, shrinking and fading, then start the shader. Show the name, price, colour and an Add to bag button bottom left. Auto-play every few seconds until the user interacts. Respect prefers-reduced-motion.</p>
  </div>
</div>
