The contact form is an envelope now
4 min read
The contact form on this site used to be a dialog. Name, email, message, send. It worked, and it was the least interesting thing here. Now, clicking contact sends a paper envelope flying across the page. It does a full loop mid-flight, lands center stage, springs open, and slides out a note card. The note is the form. When you hit send, the card slips back in, the flap closes, a wax seal stamps down, and the whole thing darts off the top of the page.
This isn't a video. It's a real three.js scene, and the form on the card is real DOM. Your browser's autofill works on a piece of 3D paper.
Shot list first, code second
Every animation on this site follows the same pattern: write the storyboard as a comment before writing any code, something I picked up from interfacecraft.dev. The envelope's is seven shots:
shot 1 FLY-IN swoops in from lower-left, full barrel
loop mid-flight, banks, settles
shot 2 OPEN flap springs open (overshoot)
shot 3 CARD-OUT note rises from the slit, turns upright
shot 4 WRITE the note is a real form; envelope idles
shot 5 CARD-IN on send: card slides back in
shot 6 SEAL flap closes, wax seal stamps down
shot 7 FLY-OUT darts off the top of the pageEach shot is a case in one useFrame switch, driven by a stage ref and a per-shot clock. No timeline library, just a TIMING object up top and easing functions doing the acting. The fly-in is a quadratic bézier with a full 2π rotation eased through the middle of the flight, which is what makes it read as a loop instead of a spin:
const p = easeInOutCubic(t);
FLY_IN_CURVE.getPoint(p, g.position);
const loop = easeInOutCubic(clamp01((p - 0.12) / 0.62));
g.rotation.x = -Math.PI * 2 * loop;Paper has spring in it
There's no physics engine in this. Rapier would have been a lot of dependency for a scripted sequence. Instead, everything that should feel physical eases out with overshoot. The flap rotates past open and settles back, the way paper with a crease actually behaves:
const easeOutBack = (t: number) => {
const c1 = 1.35;
return 1 + (c1 + 1) * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};That c1 constant is the entire personality. Crank it and the envelope turns cartoon. Zero it and the flap feels like sheet metal.
Making grey pixels read as paper
The first version of the envelope was geometrically correct and looked like a napkin rendered by a spreadsheet. Two things fixed it, and neither was more geometry.
First, a procedural paper texture with no image assets. A <canvas> gets per-pixel grain noise, about nine hundred short pressed-in fiber strokes, and faint mottling, then feeds the material twice: as the color map and as a bump map, so the surface has tooth that catches light.
Second, the light moves. A static light on a static texture still reads flat, because nothing ever changes across the surface. The key light drifts slowly (position and intensity on slow sine waves), so the grain shimmers and the folds shade differently over time. It's the difference between a photo of paper and paper.
A real form on fake paper
The note card is a boxGeometry. The form on it is real DOM, rendered onto the card's face with drei's <Html transform>, which CSS-3D-transforms a div to track the mesh. Inputs focus, tab order works, password managers do their thing.
The site is dark-native, but the card is physically white paper. So the form sits inside a light-token island: a wrapper div that redefines the theme's CSS custom properties locally. Same components, same vocabulary, inverted world:
const NOTE_VARS: React.CSSProperties = {
"--foreground": "oklch(0.24 0.01 80)",
"--primary": "oklch(0.26 0.01 80)",
"--border": "oklch(0.24 0.01 80 / 18%)",
colorScheme: "light",
// ...
};The fields dropped their boxes entirely. Each one is a ruled baseline on the paper, and focus answers back with a quiet amber underline. When you click into a field, the whole card leans in: it damps forward and centers itself in the viewport while the envelope sinks lower behind it. Focus leaves, it settles back. That one took a page of math because the card is a child of the tilted envelope group, so "centered in the viewport" means compensating for the parent's offset and rotation in local coordinates.
The bugs worth writing down
Three of them earned a permanent place in my head:
| Bug | Cause |
|---|---|
| Scene randomly went black | The WebGL-support probe created a context on every dialog open and never released it. Browsers cap live contexts globally and silently kill the oldest, which was ours. One cached probe plus WEBGL_lose_context fixed it. |
| Card clipped through the flap | The open rotation stopped at ~157°, leaving the flap leaning forward over the envelope. The card rose behind it. The flap now folds to ~200°, flat against the back. |
| A ghost form flashed on close | The 3D-vs-fallback branch depended on open, so the instant the dialog started its exit fade, the plain fallback form mounted underneath. The branch now depends only on capability, never on open state. |
The last one is the kind of bug that never shows up in a demo and always shows up for the one person you wanted to impress.
What cancel feels like
Sending gets the celebratory exit: seal, stamp, dart off the top. Cancelling needed its own gesture. Pressing escape, clicking the X, or clicking empty space lets gravity take the envelope. It tumbles off the bottom of the page from wherever it currently is, mid-open, note out, whatever. Discarded, not sent. The two exits carrying different meanings is my favorite detail in the whole thing.
For anyone on prefers-reduced-motion or a browser without WebGL, the whole production steps aside and you get the plain card form. It still works. It's just less fun to mail.