
The tunnel that makes room
WebGL, Physics, Hover
NewFree
A wedding photographer’s proofs, on glossy paper, laid out on a linen table: the pointer is a hair dryer. Where it points the air slips under the photos: the near edge curls up and flaps, the print tips up like a lid and slides away, the heat bends it, the window of the room slides across the glossy paper and the rose petals fly off. Take the dryer away and the prints drop back, slap the table and settle.
A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prints under the hair dryer</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/fraunces@5.3.0/full-italic.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/mona-sans@5.3.0/standard.css">
<link rel="stylesheet" href="style.css">
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.178.0/build/three.module.min.js"
} }
</script>
</head>
<body>
<main class="stage" data-stage>
<!-- the linen table and the glossy prints, in WebGL -->
<canvas class="stage__table" data-table aria-hidden="true"></canvas>
<!-- the label of the proofs, lying on the corner of the table -->
<header class="label" data-label>
<p class="label__kicker"><span data-line>Wren Hollis Photography</span></p>
<h1 class="label__title"><span data-line>Clara</span> <span data-line class="label__amp">&</span> <span data-line>Sam</span></h1>
<p class="label__line"><span data-line>Six glossy proofs, still damp from the lab. Give them a blow before they go in the album.</span></p>
<dl class="label__facts">
<div><dt><span data-line>Paper</span></dt><dd><span data-line>Glossy, 255 g</span></dd></div>
<div><dt><span data-line>Album</span></dt><dd><span data-line>Ready by Friday</span></dd></div>
</dl>
</header>
<!-- the cord of the dryer, then the dryer: the tip of the nozzle is the pointer -->
<svg class="cord" data-cord aria-hidden="true"><path data-cord-path d="M0 0"></path></svg>
<div class="dryer" data-dryer aria-hidden="true">
<svg class="dryer__svg" viewBox="-110 -40 340 260" overflow="visible">
<defs>
<linearGradient id="dryer-body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--dryer-light)"></stop>
<stop offset="0.45" stop-color="var(--dryer)"></stop>
<stop offset="1" stop-color="var(--dryer-dark)"></stop>
</linearGradient>
<linearGradient id="dryer-trim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--trim-light)"></stop>
<stop offset="1" stop-color="var(--trim)"></stop>
</linearGradient>
<linearGradient id="air-shade" x1="1" y1="0" x2="0" y2="0">
<stop offset="0" stop-color="#5a3a2c" stop-opacity="0.3"></stop>
<stop offset="1" stop-color="#5a3a2c" stop-opacity="0"></stop>
</linearGradient>
<linearGradient id="air-fade" x1="1" y1="0" x2="0" y2="0">
<stop offset="0" stop-color="#fff" stop-opacity="0.95"></stop>
<stop offset="1" stop-color="#fff" stop-opacity="0"></stop>
</linearGradient>
</defs>
<g data-dryer-body>
<!-- the air coming out of the nozzle -->
<g class="dryer__air dryer__air--shade" data-air>
<path d="M-6 54 C-40 49 -74 38 -124 22"></path>
<path d="M-6 60 C-46 60 -86 60 -144 61"></path>
<path d="M-6 66 C-40 71 -76 82 -128 98"></path>
<path d="M-6 57 C-34 55 -60 49 -100 41"></path>
<path d="M-6 63 C-34 66 -62 71 -102 80"></path>
</g>
<g class="dryer__air" data-air>
<path d="M-6 54 C-40 49 -74 38 -124 22"></path>
<path d="M-6 60 C-46 60 -86 60 -144 61"></path>
<path d="M-6 66 C-40 71 -76 82 -128 98"></path>
<path d="M-6 57 C-34 55 -60 49 -100 41"></path>
<path d="M-6 63 C-34 66 -62 71 -102 80"></path>
</g>
<g data-dryer-shake>
<!-- the handle, with its switch -->
<path class="dryer__handle" d="M112 92 L152 92 L184 196 Q186 210 172 212 L160 212 Q150 212 146 202 Z"></path>
<rect class="dryer__switch" x="150" y="120" width="12" height="30" rx="6" transform="rotate(-17 156 135)"></rect>
<rect class="dryer__switch-knob" data-switch x="152" y="123" width="8" height="12" rx="4" transform="rotate(-17 156 135)"></rect>
<!-- the barrel -->
<rect class="dryer__barrel" x="26" y="16" width="160" height="88" rx="44"></rect>
<path class="dryer__shine" d="M54 30 Q110 22 168 30"></path>
<rect class="dryer__band" x="62" y="17" width="10" height="86" rx="3"></rect>
<!-- the rear cap with the vents -->
<ellipse class="dryer__cap" cx="184" cy="60" rx="16" ry="42"></ellipse>
<g class="dryer__vents">
<path d="M186 32 L186 88"></path>
<path d="M193 38 L193 82"></path>
<path d="M179 34 L179 86"></path>
</g>
<!-- the concentrator nozzle -->
<path class="dryer__nozzle" d="M0 44 L36 30 L36 90 L0 76 Q-3 60 0 44 Z"></path>
<path class="dryer__slot" d="M1 47 L1 73"></path>
</g>
</g>
</svg>
</div>
<p class="hint" data-hint><span>Move the dryer over the prints. Hold to blow harder.</span></p>
</main>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script type="module" src="script.js"></script>
</body>
</html>Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.
29 values to change
With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:
Take “Prints under the hair dryer” from Animorio and fit it into my siteEvery photo is a 640 by 800 canvas painted by the script: skies and meadows in gradients, figures made of Bézier curves, rings made of many small pieces of ellipse each with its own light, flowers of overlapping petals. The parts out of focus are only the blurred shadow of a shape moved off the sheet, which also works where the canvas blur filter is missing. Then the film grain, the vignette and, for one, black and white. They become the textures of the front; the back is another canvas with the lab’s line.
Every print is a Three.js plane of 21 by 26 points, bent at every frame on the processor. First the heat, a cylinder that keeps the length of the paper; then the side facing the dryer, which past a line wraps on a fixed radius (ROLL) and, past the steepest point, goes on straight, so it never rolls over itself; then the trembling edges and the tilt. At the end the sheet is raised until its lowest point touches the table: that is how it rests on the opposite edge.
The pointer is carried onto the table with a ray. For every photo the distance between the stream and its rectangle is measured: the force drops off like a bell (REACH). The side that curls turns smoothly towards the stream; the curl, the tilt, the edges and the floating are springs with little damping, which drop and bounce when the stream goes away. The push moves the photo away from the stream, a hit from the side turns it, and a spring (SETTLE) takes it back to its place.
The front is a physical material with a clear varnish on top (clearcoat, GLOSS): underneath it is almost matte, the varnish reflects sharply. The room to reflect is a small separate scene, a dark room with a four-pane window and a pale board, baked into an environment map. When the paper bends the reflected window slides and warps. A light from above casts the shadows: a raised edge shades the table and the photo itself.
The dryer is an SVG with its nozzle on the pointer, leaning against the way it moves; the stream is made of dashes that run, the cord is a Bézier curve with one point on a spring, so it dangles. The petals are a single instanced mesh: each one has a position, a height, a speed and two turns. The stream pushes them away and, when it is strong, lifts them; in the air they fall slowly and sway, on the table they stop and settle on one face.
| Name | File | Default | What it changes |
|---|---|---|---|
BLOW | script.js | 1 | How strong the air of the dryer is. |
REACH | script.js | 1 | How far the air reaches across the table: more, and the photos nearby move too. |
CURL | script.js | 1 | How much the side of the photo facing the dryer curls up. |
FLUTTER | script.js | 1 | How much the lifted edges flap and tremble. |
HEAT | script.js | 1 | How much the heat bends the photos like a gutter (0 = never). |
GLOSS | script.js | 1 | How glossy the paper is: 0 is a matte paper, with no reflections. |
SETTLE | script.js | 1 | How fast the photos drop back and return to their place. |
--dryer | style.css | #e7a99c | The colour of the dryer (with --dryer-light and --dryer-dark for its light and shade; the table is --table). |
For the site of a wedding or portrait photographer, a print lab, a studio that delivers albums: the proofs page, the gallery of work or the home page. The photos can be touched like real ones fresh from the printer, and playing with the dryer makes you want to look at them all.
The label sits at the top and the photos in two columns of three. The finger is the dryer: it appears where you touch and blows while you drag, with the same physics, the same reflections and the same petals, only smaller and fewer petals. With no button to hold, the full blast is not there.
Nothing moves: the photos are already on the table, flat, and the petals in their place; there is no dryer. The pointer is a lamp: where it passes, the glossy paper lights up with a reflection, which fades in and out.
Three.js 0.178.0, GSAP 3.15.0, Fraunces (font variabile) 5.3.0, Mona Sans (font variabile) 5.3.0.
Compressed with gzip, the three demo files weigh 22 KB; the libraries: GSAP 28 KB, Three.js 174 KB.
Tested on 13 October 2026
Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.
With your account’s email and password.
or with your email
You’re signed in as .
With your account, you can download the zip of every animation and every pack, for free.