The following plan was written by Qwen 3.6 35B-A3B using pi.dev and implemented by Claude Code with Sonnet 4.6.


Letter — Scroll-Driven Envelope Animation

Overview

Scroll-driven CSS animation of an envelope opening (unseal), a letter rising out of it (slide), then unfolding in 3 panels while settling back down resting askew above the envelope (settle).

Structure: Empty phase sections + single sticky scene

Empty <section> elements provide scroll range and view-timelines. A single sticky #scene (sibling to all phases) holds everything.

body (min-height: ~350vh — ~100vh per phase + slack)
│
├── #scene                   — sticky, perspective, holds all DOM
│   ├── #envelope-back       — back panel (visible when flap opens)
│   ├── #envelope-front      — static envelope body (clip away top)
│   ├── #flap                — front flap (animates open)
│   └── #letter              — animates translateY, rotateX, translateX, rotate
│       ├── #panel-toptop 1/3 image slice (front + back)
│       ├── #panel-mid       — middle 1/3 image slice (front only)
│       └── #panel-botbottom 1/3 image slice (front + back)
│
├── #phase-unseal            — empty, view-timeline: --unseal
├── #phase-slide             — empty, view-timeline: --slide
└── #phase-settle            — empty, view-timeline: --settle

Why empty sections + sibling scene?

Each phase section is empty — it exists only to define a view-timeline via its own scroll. The #scene is a single DOM sibling to all phase sections, sticky within the body. This means:

#scene {
  position: sticky;
  top: 50%;
  transform: translateY(-50%);
  width: 400px;
  height: 400px;
  perspective: 1200px;
}

Depth ordering: translateZ, not z-index

The flap must be in front of the letter when closed, behind it when open. We use translateZ within a shared transform-style: preserve-3d context so the browser's 3D painter handles the sweep naturally.

Z-layer hierarchy

translateZ(+2px)    — front flap (covers letter)
translateZ(+1px)    — letter (hidden inside envelope)
translateZ(0)       — static-flaps (envelope body)

When the front flap rotates around its top edge, it sweeps through Z space and ends up behind the letter at -180deg. No layer swapping needed.


Envelope: #static-flaps + #flap

No #envelope wrapper. The envelope is two sibling elements:

#envelope-back

A simple rectangle behind everything — the interior back of the envelope, visible once the front flap opens. Placed at translateZ(-1px).

#envelope-front

A single element representing the left, right, and bottom flaps — combined into one shape via clip-path that cuts away the top triangle. The result looks like the U-shaped body of an envelope with the top open.

#envelope-front {
  /* rectangle, clip-path removes top triangle to create envelope opening */
  clip-path: polygon(
    0% 30%,           /* left edge, 30% down (start of left flap) */
    0% 100%,          /* bottom-left corner */
    100% 100%,        /* bottom-right corner */
    100% 30%,         /* right edge, 30% down (start of right flap) */
    50% 0%            /* center-top (V cut = envelope opening) */
  );
}

This single element represents the left, right, and bottom flaps folded inward.

#flap

The front flap — a rectangle positioned at the top of the envelope. It rotates around top center:


Phase 1: Unseal (flap peels back)

Section: #phase-unseal
View timeline: --unseal
What animates: #flap's rotateX

Flap mechanics

Layer ordering

translateZ(+2px)#flap (front of envelope)
translateZ(+1px)#letter (hidden inside)
translateZ(0)#envelope-front (U-shaped body)
translateZ(-1px)#envelope-back (interior back)

As the flap rotates to -180deg, its Z position sweeps through space, and the 3D browser painter naturally reveals the letter behind it.


Phase 2: Slide (letter rises up and out)

Section: #phase-slide
View timeline: --slide
What animates: #letter's translateY

Letter mechanics

Folded state during slide

animation-fill-mode: both on each unfold-* animation (in phase 3) holds the folded start values backward in time, so the panels stay folded during phases 1 and 2 without any additional CSS needed.

All 3 panels stack on top of each other (one panel tall):

#panel-top   rotateX(180deg)  — folded down over the letter
#panel-mid   rotateX(0deg)    — middle, stays flat
#panel-bot   rotateX(-180deg) — folded up underneath

They appear as a single rectangle. The panel that's on top is #panel-top (folded down).


Phase 3: Settle (unfold + settle concurrently)

Section: #phase-settle
View timeline: --settle
What animates: panel rotations + letter position, all concurrently

Unfolding and settling happen together. As the letter unfolds, it also moves to its final askew position.

Panel layout (0deg = flat/unfolded)

+----------------+
| #panel-top     |  ← top third (folded down)
+----------------+
| #panel-mid     |  ← middle third (spine, stays flat)
+----------------+
| #panel-bot     |  ← bottom third (folded up)
+----------------+

Each panel: full letter width, 1/3 letter height.

Panel front/back faces

Each panel with faces uses the billow.html pattern:

<div class="panel" id="panel-top">
  <div class="face front"></div>
  <div class="face back"></div>
</div>
.panel .face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
}
.panel .back {
  transform: rotateX(180deg);
}

Fold lines

#panel-top (top)
  ────────────  fold between top and mid
#panel-mid (middle)  ← stays flat
  ────────────  fold between mid and bot
#panel-bot (bottom)

Unfold direction: -180deg → 0deg

transform-origin: bottom center for all panels.

The fold directions are opposite for top and bottom:

Panel Start End Direction
#panel-top rotateX(180deg) rotateX(0deg) top swings away then down (positive X)
#panel-mid rotateX(0deg) rotateX(0deg) stays flat
#panel-bot rotateX(-180deg) rotateX(0deg) bottom swings toward then down (negative X)

#panel-bot starts at rotateX(-180deg) because it's folded up — its top edge comes toward you. Unfolding swings it down the other way (negative rotation → 0).

Timing within #phase-settle (concurrent)

Scroll progress #panel-bot #panel-top #panel-mid Letter position
0% starts unfolding folded (180) flat raised (peak)
50% flat starts unfolding flat settling
100% flat flat flat at rest, askew

Settle position

The whole letter group moves to its final askew resting position:

@keyframes letter-settle {
  0%   { translate: 0 -300px; rotate: 0; }
  40%  { translate: 8px -330px; rotate: -1deg; }
  100% { translate: 10px -260px; rotate: -3deg; }
}

Both panel unfold and settle use the same --settle timeline with different animation-range values.


CSS Architecture

View timeline setup

Named view-timelines are only accessible to descendants of the defining element by default. Since #scene is a sibling of the phase sections, timeline-scope must be declared on their common ancestor (body) to share the timelines across siblings.

body {
  timeline-scope: --unseal, --slide, --settle;
}

#phase-unseal   { view-timeline: --unseal; }
#phase-slide    { view-timeline: --slide; }
#phase-settle   { view-timeline: --settle; }

Animations

/* Phase 1: flap opens */
#flap {
  animation: flap-open linear both;
  animation-timeline: --unseal;
}

/* Phase 2: letter slides up */
#letter {
  animation: letter-slide linear both;
  animation-timeline: --slide;
}

/* Phase 3: panels unfold (concurrent) + letter settles */
#panel-bot {
  animation: unfold-bot linear both;
  animation-timeline: --settle;
  animation-range: 0% 50%;
}
#panel-top {
  animation: unfold-top linear both;
  animation-timeline: --settle;
  animation-range: 50% 100%;
}
#letter {
  animation: letter-settle ease-out both;
  animation-timeline: --settle;
}

Dimensions

Element Width Height
Envelope (#envelope-front + #envelope-back) 320px 210px
Letter (unfolded) 300px 270px
Each panel 300px 90px (1/3 height)
Letter folded (stacked) 300px 90px (one panel tall)

Key CSS Properties

Property Used for
perspective 3D on #scene
transform-style: preserve-3d Scene context; also required on #letter so panel rotateX renders in 3D
backface-visibility Panel faces, flap faces
rotateX Flap opening, panel unfolding
translateZ Depth ordering (no z-index)
translateY Letter rising/settling
translateX Askew offset
rotate Askew tilt
clip-path #static-flaps envelope shape
view-timeline / animation-timeline Scroll-driven timing
position: sticky Scene stays centered

Later Elaborations