The problem with selling perfume online is that you can't smell a screen. Every perfume page answers it the same way, with a list: top notes, heart notes, base notes. I wanted that list to be something you watch happen. Spray one at avivashishta.com/lab/scent.
Hold the bottle (or the space bar) and it sprays. A cloud bursts out of the nozzle, drifts and curls on a breeze, then gathers into the names of its notes, and fades in the order a scent does on skin: the top note first, the base note last. Spray a second one while the first is in the air and they hang side by side, the way people layer two scents.
The short version: Codex makes a product shot of each bottle, cut out with macOS Vision. The spray is a few thousand particles on a 2D canvas. Each one belongs to a note, has a colour and a lifetime, and is given a point inside that note's name, sampled from the word drawn on a hidden canvas. For its first half second it flies and drifts; then a spring pulls it into its letter; near the end of its life the spring lets go and it fades.
Step 1: the bottles (Codex)
On black, because glass shows its edges best against black, and Vision cuts it out either way:
A luxury fragrance product photo of a heavy faceted clear glass perfume bottle filled with amber liquid and a chunky gold cap, unbranded, no text, no labels, no logos, standing upright, front view, centered with generous margin, on a pure seamless black background (#000000), dramatic soft studio light with crisp highlights on the glass, photoreal. Square 1:1, 1024x1024.
bun scripts/prepare-scent.mjs ./raw
Step 2: words to points
Each note's name is drawn once on an invisible canvas in the site's serif (read off the page's own headline, because the font loader renames the family), and every few pixels where the letters are solid becomes a target point:
x.font = `italic 400 ${size}px ${family}`; // the site's serif, read off the page
x.fillText(word, w / 2, h / 2);
for (let yy = 0; yy < h; yy += step) for (let xx = 0; xx < w; xx += step)
if (d[(yy * w + xx) * 4 + 3] > 120) pts.push([cx - w / 2 + xx, cy - h / 2 + yy]);
The three names are stacked to the right of the bottle: top, heart, base.
Step 3: a particle's life
While you hold the bottle, 110 particles a frame leave the nozzle in a cone, each assigned a note (a bit more often the top note) and a random point in that note's word. Then:
// First the burst and the breeze, then the pull into its word, then a slow loosening as it fades.
const pull = clamp((age - 0.5) / 1.2) * (1 - clamp((age - life * 0.7) / (life * 0.3)));
p.vx += fx * 60 * dt * (1 - pull); // the breeze
p.vx += (p.tx - p.x) * 16 * pull * dt; // the spring into its letter
The breeze is a flow field made of a few sines, turned sideways so it swirls instead of collecting in sinks, which is the idea behind curl noise without the noise. The lifetimes come from perfumery: the top note lives 3.6 seconds, the heart 6.5, the base 9.5. So the words go in that order, as they would on your wrist over a day.
Particles are drawn with multiply on the paper background, so dense parts get darker like ink in water, and two clouds that overlap mix their colours.
The small things
- Layering: spray a second scent while the first is still there and its words appear beside the first, and the panel names the pair.
- The bottle dips a few pixels while you hold it, like a finger on the pump.
- The shelf along the bottom has all six; the notes in the panel use the same colours as the cloud.
Build your own
Build a perfume page with a visible spray (React, canvas 2D, no libraries). Each product has a cut-out bottle image and three notes (top, heart, base) with a name and colour. While the user holds the bottle (pointer or space), emit about 110 particles a frame from the nozzle in a cone up and to the right. Each particle gets a note, that note's colour, a lifetime (top 3.6 s, heart 6.5 s, base 9.5 s) and a target point sampled from that note's name drawn on an offscreen canvas in a serif font, the three names stacked to the right of the bottle.
Each frame: add a swirling flow field (sums of sines) and a little lift for the first half second, then blend into a spring toward the target, release the spring over the last 30% of life and fade out. Apply drag. Draw particles as tiny squares with multiply blending on a light background. Show the product with notes, sizes, price and Add to bag, and a shelf of bottles to switch.