← home

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.

The whole storyboard, miniaturized. Watch the loop, then seal it.

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 page

Each 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 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.

0.60
Feel the constant. Zero bounce is sheet metal; full bounce is Looney Tunes. Production sits around the middle.

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.

Toggle them independently. Texture without drift is a photo; drift without texture is a napkin.

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:

BugCause
Scene randomly went blackThe 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 flapThe 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 closeThe 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.

Get new posts by email. No spam, unsubscribe anytime.