The following plan was written by Qwen 3.6 35B-A3B using pi.dev and implemented by Claude Code with Sonnet 4.6.
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).
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-top — top 1/3 image slice (front + back)
│ ├── #panel-mid — middle 1/3 image slice (front only)
│ └── #panel-bot — bottom 1/3 image slice (front + back)
│
├── #phase-unseal — empty, view-timeline: --unseal
├── #phase-slide — empty, view-timeline: --slide
└── #phase-settle — empty, view-timeline: --settle
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;
}
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.
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.
No #envelope wrapper. The envelope is two sibling elements:
A simple rectangle behind everything — the interior back of the envelope, visible once the front flap opens. Placed at translateZ(-1px).
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.
The front flap — a rectangle positioned at the top of the envelope. It rotates around top center:
transform-origin: top centerrotateX from 0deg → -180deg — peels back to lie flat against the interiortranslateZ(+2px) — in front of everythingSection: #phase-unseal
View timeline: --unseal
What animates: #flap's rotateX
transform-origin: top center — rotates around the top edgerotateX from 0deg → -180deg — peels back to lie flat against the interior backperspective: 1200px on #scene so the 3D rotation is visiblebackface-visibility).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.
Section: #phase-slide
View timeline: --slide
What animates: #letter's translateY
translateY from 0 (inside) → -300px (fully above envelope)#panel-top, #panel-mid, #panel-bot), all folded flatanimation-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).
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-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-mid: front face only (always visible)#panel-top: front + back (back exposed as it swings down)#panel-bot: front + back (back exposed as it swings down)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);
}
#panel-top (top)
──────────── fold between top and mid
#panel-mid (middle) ← stays flat
──────────── fold between mid and bot
#panel-bot (bottom)
rotateX(180deg) — fully foldedrotateX(0deg) — flat/unfolded (unstyled, natural state)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).
#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 |
The whole letter group moves to its final askew resting position:
translateY(-260px) translateX(10px) rotate(-3deg)@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.
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; }
/* 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;
}
| 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) |
| 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 |
#panel-top = top 1/3 of source, #panel-mid = middle 1/3, #panel-bot = bottom 1/3