웹 디자인 이펙트

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

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.

fullscreen-expansion-transition.html
<!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

Seen in

@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:15