Fullscreen expansion transition
Scroll opens a small card out to full bleed. The card never scales — the window cut out of it does — so the artwork and type inside stay pixel-exact the whole way.
How it works
- The card is always full-bleed. What animates is
clip-path: inset()— the window cut out of it — going from a centred rectangle with an 18px radius toinset(0)with no radius. - This is the whole reason to prefer clipping over scaling a small card up. A scaled card stretches everything inside it, so every child needs a counter-scale, and text is resampled at every intermediate size. Clipping leaves the contents rendered at their final size from the first frame.
- JS computes exactly one number — scroll progress 0→1 — and writes it to
--pon the stage. Every visual decision lives in CSS against that variable, so the entire transition can be inspected by typing a number into devtools instead of stepping through a scroll handler. - 🩸 Never write
--p: 0on the element that consumes it as a "default". The value is set on the stage and inherits down; a local declaration shadows the inherited one, and the card sits frozen at 0 while--pon the stage animates perfectly. Nothing errors, and devtools cheerfully shows you both values on two different elements. Usevar(--p, 0)instead — a fallback only applies when the property is genuinely unset. This cost me a debugging round on this very sample. - The push-in on the artwork ends at
scale(1), not at 1.02 or 0.98. Landing on exactly 1 means the final resting frame is an unscaled render; anything else leaves the hero permanently slightly soft. - The backdrop wordmark moves at a different rate to the card. Without that second rate the expansion reads as a box getting bigger rather than as the camera moving in.
- The scroll listener is
{ passive: true }— it never callspreventDefault, and declaring that lets the browser begin scrolling without waiting to find out.
Numbers
- Scroll distance
- 320% of the viewport
- Start window
- inset(26% 32%) round 18px
- End window
- inset(0) round 0
- Artwork push-in
- scale 1.14 → 1.00
- Backdrop rate
- −14% translate, scale +0.25
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a scroll-driven fullscreen expansion transition in vanilla HTML/CSS/JS. Use a scroll container with a track 320% of the viewport height and a sticky stage. The media card must be absolutely positioned full-bleed at all times; animate only its clip-path, from inset(26% 32% round 18px) at progress 0 to inset(0 round 0) at progress 1 — do NOT scale a small card up, because that stretches its contents and resamples the type at every intermediate size. In JS compute a single scroll progress value 0→1 and write it to a CSS custom property --p; express every visual change in CSS as a calc() against --p. Give the artwork inside the card a push-in from scale(1.14) to exactly scale(1.00) so the final frame is an unscaled render. Move a background wordmark at a different rate (translateX to −14%, scale to 1.25) so the effect reads as camera movement rather than a growing box. Register the scroll listener with { passive: true }.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fullscreen expansion transition</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }
.scroller { height: 100%; overflow-y: auto; background: #0a0a10; scrollbar-width: thin; }
.track { height: 320%; position: relative; }
/* 🩸 100vh,不是 100%。`.track` 高 320%,所以 `height: 100%` 会把舞台撑成
整条轨道那么高,内容居中到看不见的地方去(卡片会出现在视口下方三分之一处、
被切掉)。数值照样在变,所以只验机制是发现不了的。 */
.stage { position: sticky; top: 0; height: 100vh; overflow: hidden; }
/* Background wordmark, parked behind and moving at a different rate so the
expansion reads as depth rather than as a box getting bigger. */
.backdrop {
position: absolute; inset: 0;
display: grid; place-items: center;
background: radial-gradient(80% 70% at 50% 40%, #1b1b2e, #0a0a10 70%);
}
.backdrop span {
font-size: clamp(60px, 18vw, 190px); font-weight: 800;
letter-spacing: -0.05em; color: #ffffff0f; white-space: nowrap;
transform: translateX(calc(var(--p, 0) * -14%)) scale(calc(1 + var(--p, 0) * 0.25));
}
/* The card is ALWAYS full-bleed. What changes is the window cut out of it.
Scaling a small card up instead would stretch its contents and force a
counter-scale on every child; clipping leaves the artwork untouched at
every frame, which is why the type inside stays crisp. */
.card {
position: absolute; inset: 0;
/* 🩸 Do NOT write `--p: 0` here as a "default". JS sets --p on the stage and
it inherits down; a local declaration SHADOWS the inherited value, so the
card would sit frozen at 0 forever while --p on the stage animates
perfectly. Nothing errors and devtools shows both values, one on each
element. Use a fallback in var() instead — it only applies when the
property is genuinely unset. */
clip-path: inset(
calc((1 - var(--p, 0)) * 26%) calc((1 - var(--p, 0)) * 32%)
round calc((1 - var(--p, 0)) * 18px)
);
}
.card .art {
position: absolute; inset: 0;
background:
radial-gradient(60% 80% at 25% 20%, #ff5abf, transparent 60%),
radial-gradient(70% 70% at 80% 75%, #29e0ff, transparent 60%),
linear-gradient(140deg, #7b2ff7, #1b1b3a);
/* A slow push-in under the reveal. Ends at 1 so the final frame is a clean
1:1 render rather than a fractionally scaled one. */
transform: scale(calc(1.14 - 0.14 * var(--p, 0)));
}
.card .label {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 18px 22px;
background: linear-gradient(transparent, #00000099);
color: #fff;
opacity: var(--p, 0);
}
.card .label b { display: block; font-size: 17px; letter-spacing: -0.01em; }
.card .label i {
display: block; margin-top: 3px; font-style: normal;
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: #ffffff8c;
}
.rail {
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
width: 108px; height: 3px; border-radius: 99px; background: #ffffff24; overflow: hidden;
}
.rail i { display: block; height: 100%; background: #fff; transform-origin: left; transform: scaleX(var(--p, 0)); }
.cue {
position: absolute; left: 50%; top: 16px; transform: translateX(-50%);
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
color: #ffffff66; opacity: calc(1 - var(--p, 0) * 2);
}
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage" id="stage">
<div class="backdrop"><span>WORKS</span></div>
<div class="card">
<div class="art"></div>
<div class="label"><b>Wear Go Land</b><i>Fullscreen expansion</i></div>
</div>
<div class="cue">scroll ↓</div>
<div class="rail"><i></i></div>
</div>
</div>
</div>
<script>
const scroller = document.getElementById("scroller");
const stage = document.getElementById("stage");
const update = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
// Progress is the only thing JS computes. Everything visual is expressed in
// CSS against --p, so the whole transition can be inspected by setting one
// number in devtools instead of stepping through a scroll handler.
const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
stage.style.setProperty("--p", p.toFixed(4));
};
// passive: the handler never calls preventDefault, and saying so lets the
// browser start scrolling without waiting to find out.
scroller.addEventListener("scroll", update, { passive: true });
addEventListener("resize", update);
update();
</script>Where it breaks
- The custom-property shadowing above is the kind of bug this whole format exists to record: the progress number was provably correct, the CSS was provably correct, and the thing still did not move.
clip-pathwith percentages resolves against the element's own box, so the start window is a fixed fraction of the card, not of the viewport. On a very wide viewport the closed state gets wide too — clamp it withmin()if the card must stay a fixed size.- Sticky positioning fails silently if any ancestor has
overflow: hidden. The stage stops sticking, the transition never scrubs, and nothing anywhere reports an error. - Driving this from a scroll handler is universal but not free.
animation-timeline: scroll()does the same thing off the main thread where it is supported — worth it if the page has several of these.
Seen in
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:15