Zoom-through Hero

A pinned hero where the display word DEEPER is punched out of a page-coloured panel; scrolling scales the word 40× around the stem of one letter so the camera flies through it and the image panel behind is revealed.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1517 words of exact values — rewritten live as you tune the Playground.

prompt.md · Zoom-through Hero
### EFFECT: Zoom-through Hero
**What it does (visual):** A tall intro ("Go deeper") leads into a full-viewport hero. At rest the hero shows a huge, heavy serif word — "DEEPER" — centred on the page. The word is not coloured text: it is a hole punched through a page-coloured panel, and through the letters you see a deep gradient image (a `#228ea0` teal glow over indigo) with film grain. A small mono hint "Scroll to dive" sits at the bottom. As the user scrolls, the section stays pinned and the word scales up — slowly at first, then accelerating (`power2.in`) — around a point on the stem of the second letter "E". The letters grow past the edges of the screen; the stem of the E becomes a widening window; at about 80% of the scroll the window is bigger than the viewport and the panel is gone. Simultaneously the image behind zooms *out* from 1.3 → 1 (so it feels like the camera moves forward) and, from 62% onward, an eyebrow, a two-line serif headline and a paragraph rise 48px into place over the bottom-left of the image. The pin releases after 250% of viewport height and the page continues with an outro. Scrolling up reverses everything (scrubbed, 1s catch-up).

**Tech stack:**
- GSAP 3.13+ and ScrollTrigger. No other plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Inline SVG `<mask>` for the cut-out; a `<canvas>` painted once for the procedural image (swap for an `<img>` in production).
- Fonts: a heavy variable serif for the mask word (demo: Fraunces 800, `opsz 144`), light weight (300) for the headline.

**HTML structure:**
```html
<section class="zt-intro"><p class="zt-intro__label">Scroll · Zoom-through hero</p><h1 class="zt-intro__title">Go <em>deeper</em></h1></section>

<section class="zt">
  <div class="zt__reveal">
    <canvas class="zt__canvas" aria-hidden="true"></canvas>
    <div class="zt__content">
      <p class="zt__eyebrow">Issue 04 · Below the surface</p>
      <h2 class="zt__headline">What the surface hides, the depth keeps.</h2>
      <p class="zt__copy">A field study of light at forty metres…</p>
    </div>
  </div>
  <svg class="zt__cover" aria-hidden="true" focusable="false">
    <defs>
      <mask id="zt-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
        <rect width="100%" height="100%" fill="#fff"/>
        <text class="zt__word" x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#000">DEEPER</text>
      </mask>
    </defs>
    <rect class="zt__panel" width="100%" height="100%" mask="url(#zt-mask)"/>
    <!-- invisible twin used only for measuring glyph geometry (text inside <defs> has no layout) -->
    <text class="zt__word zt__word--measure" x="50%" y="50%" text-anchor="middle" dominant-baseline="central">DEEPER</text>
  </svg>
  <p class="zt__hint">Scroll to dive ↓</p>
</section>

<section class="zt-outro"><p>Surfaced.</p></section>
```

**CSS requirements:**
- `.zt-intro`, `.zt-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.zt-intro__title`: display serif, `font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9;` the `<em>` is italic weight 200.
- `.zt`: `position: relative; height: 100vh; overflow: hidden; background: var(--bg, #0a0a0a);` — this is the pinned element.
- `.zt__reveal`: `position: absolute; inset: 0; transform-origin: 50% 50%; will-change: transform;`
- `.zt__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%;`
- `.zt__content`: `position: absolute; inset: 0; display: grid; align-content: end; gap: 1rem; padding: clamp(1.5rem, 6vw, 5rem); color: #f2efe9;` (the image is always dark, so this colour is deliberate and does not flip with the theme).
- `.zt__eyebrow`: mono, 11px, uppercase, `letter-spacing: .14em; color: rgba(242,239,233,.6)`.
- `.zt__headline`: display serif, `font-weight: 300; font-size: clamp(2rem, 5.5vw, 5.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 14ch;`
- `.zt__copy`: `max-width: 46ch; color: rgba(242,239,233,.72); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5;`
- `.zt__cover`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible;` (no `viewBox` — user units are CSS pixels so `maskUnits="userSpaceOnUse"` with `100%` sizes covers the viewport at any aspect ratio).
- `.zt__panel`: `fill: var(--bg, #0a0a0a);` — must equal the page background so the panel is invisible and only the hole reads.
- `.zt__word`: `font-family: var(--font-display); font-weight: 800; font-size: clamp(4.5rem, 22vw, 20rem); letter-spacing: -0.05em; font-variation-settings: 'opsz' 144;` (SVG `<text>` honours CSS font properties, including `vw` units).
- `.zt__word--measure`: `fill: none; opacity: 0; pointer-events: none;` (keep `visibility: visible` — hidden elements lose geometry).
- `.zt__hint`: `position: absolute; left: 50%; bottom: 2rem; transform: translateX(-50%);` mono 11px uppercase muted.
- Reduced motion: `.zt__cover, .zt__hint { display: none; }`.

**Animation spec (exact values):**
1. Paint the canvas once (and on resize, debounced 120ms): DPR `Math.min(devicePixelRatio, 2)`; fill `#071e2a`; three radial glows — the key hue `#228ea0` (teal) at (62%, 36%) radius `0.7 × max(w,h)`, fading through a darker copy of the same hue (channels × 0.41/0.55/0.625, alpha .7) at stop .45; indigo `rgba(58,50,150,.9)` at (18%, 85%) radius `0.6 × max(w,h)`; lime glint `rgba(212,255,58,.42)` at (80%, 18%) radius `0.32 × min(w,h)`; a bottom vignette `linear-gradient` from transparent at 55% height to `rgba(2,8,14,.85)` at 100%; then a 192px random-noise tile as a repeating pattern at `globalAlpha .11`, `globalCompositeOperation 'overlay'`.
2. Dive origin (function, re-evaluated on refresh): `const ext = measure.getExtentOfChar(1); origin = `${ext.x + ext.width * 0.26} ${ext.y + ext.height * 0.5}`` — the stem of the "E" sits ~0.26 of the way into the glyph's advance box at weight 800.
3. `gsap.set('.zt__content > *', { y: 48, opacity: 0 })`.
4. Master scrubbed timeline:
   ```js
   const tl = gsap.timeline({ defaults: { ease: 'none' }, scrollTrigger: {
     trigger: '.zt', start: 'top top', end: '+=250%', scrub: 1,
     pin: true, pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, markers: false } });
   ```
   - `tl.to('.zt__hint', { opacity: 0, duration: 0.08 }, 0)` — hint disappears in the first 8% of the pin.
   - `tl.fromTo(maskText, { scale: 1, svgOrigin: () => origin() }, { scale: 40, svgOrigin: () => origin(), ease: 'power2.in', duration: 1 }, 0)` — target: the `<text>` inside the mask; scale 1 → 40; `power2.in` so the dive accelerates. (GSAP writes a `transform` attribute on the SVG text; browsers re-rasterise the mask.)
   - `tl.fromTo('.zt__reveal', { scale: 1.3 }, { scale: 1, ease: 'power1.out', duration: 1 }, 0)` — counter-zoom of the image.
   - `tl.to('.zt__panel', { opacity: 0, duration: 0.14 }, 0.8)` — safety burn-off of the panel from 80% → 94% of the pin.
   - `tl.to('.zt__content > *', { y: 0, opacity: 1, duration: 0.3, stagger: 0.05, ease: 'power3.out' }, 0.62)` — eyebrow, headline, copy rise between 62% and ~100%.
   Timeline durations are in "scrub units": the whole timeline (1.0) maps onto the 250vh of scroll.
5. `document.fonts.ready.then(() => { tl.invalidate(); ScrollTrigger.refresh(); })` so the origin is measured with the real font.
6. Intro: `gsap.from('.zt-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.

**Interaction:** Scroll only, fully reversible. Mouse wheel, trackpad and keyboard all work because the effect reads page scroll. Nothing is clickable inside the hero.

**Responsive behavior:** The word's `clamp(4.5rem, 22vw, 20rem)` keeps "DEEPER" inside a 375px viewport (≈82px font). Because the dive point is on a stem, a scale of 40 fills both 1200×750 and 375×812 (stem ≈ 0.19em × 40 ≥ viewport width; glyph height × 40 ≥ viewport height); no `matchMedia` branch is needed for geometry. The content block uses `clamp()` padding and a `14ch` headline max-width so it stacks cleanly on phones. Pinning is kept on mobile — the section is exactly `100vh` and nothing inside needs the browser chrome.

**Accessibility:** With `prefers-reduced-motion: reduce`: do not create the ScrollTrigger; hide `.zt__cover` and `.zt__hint`; set the content to `opacity: 1, y: 0` so the hero is simply the image with its headline (the final state) — the section is still 100vh but scrolls normally. The SVG and canvas are `aria-hidden`; the headline and paragraph are real DOM text. Give the intro heading an `<h1>` and the hero headline an `<h2>`.

**Performance rules:** Only `transform` (scale) and `opacity` are animated. `will-change: transform` on `.zt__reveal` only. The canvas is painted once per resize, never per frame; halve the DPR when embedded as a preview. Scaling a masked SVG text to 40× re-rasterises the mask each frame — keep the mask to one `<rect>` and one `<text>` and avoid filters inside it. Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` also unpins); remove the resize listener in the same cleanup.

**Customization knobs:**
- `SCALE_MAX` (40) — how far you fly; raise it for thinner fonts.
- `FOCUS_CHAR` (1) and `STEM_X` (0.26) — which letter and where on it the camera dives.
- `ease: 'power2.in'` on the word — `expo.in` for a more violent final rush, `power1.in` for a gentler drift.
- `scrub` (1) — seconds of catch-up; 0 = locked to the scroll position.
- Panel burn-off position/length (`0.8`, `0.14`).
- `end: '+=250%'` — longer pin = slower dive.
- Canvas palette: the key glow `#228ea0` (plus the indigo and lime glows) — or replace the canvas with an `<img>`.

**Acceptance criteria:**
- [ ] At rest the word reads as gradient-filled text on the page background; no visible panel edges, no seams.
- [ ] Scrolling scales the word around the stem of the "E"; by ~80% of the pin the hole is larger than the viewport and the whole image is visible.
- [ ] Headline block rises into the bottom-left between 62% and 100%; the hint disappears in the first 8%.
- [ ] Scrolling back up restores the word exactly; resizing re-measures the origin (no drift).
- [ ] Reduced-motion users see the image + headline with no pin, no cover.
- [ ] No console errors; thumbnail at 36% scroll shows the word at roughly 3× with the image visible through it.

Source

Full source code

<section class="zt-intro">
  <p class="zt-intro__label">Scroll · Zoom-through hero</p>
  <h1 class="zt-intro__title">Go <em>deeper</em></h1>
  <p class="zt-intro__copy">Keep scrolling — the word below becomes a doorway and the camera flies straight through it.</p>
</section>

<section class="zt">
  <div class="zt__reveal">
    <canvas class="zt__canvas" aria-hidden="true"></canvas>
    <div class="zt__content">
      <p class="zt__eyebrow">Issue 04 · Below the surface</p>
      <h2 class="zt__headline">What the surface hides, the depth keeps.</h2>
      <p class="zt__copy">A field study of light at forty metres: how colour thins to a single blue, how sound slows, how a room full of water goes completely quiet.</p>
    </div>
  </div>
  <svg class="zt__cover" aria-hidden="true" focusable="false">
    <defs>
      <mask id="zt-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
        <rect width="100%" height="100%" fill="#fff"/>
        <text class="zt__word" x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#000">DEEPER</text>
      </mask>
    </defs>
    <rect class="zt__panel" width="100%" height="100%" mask="url(#zt-mask)"/>
    <text class="zt__word zt__word--measure" x="50%" y="50%" text-anchor="middle" dominant-baseline="central">DEEPER</text>
  </svg>
  <p class="zt__hint">Scroll to dive ↓</p>
</section>

<section class="zt-outro">
  <p class="zt-outro__text">Surfaced. The page continues normally from here.</p>
</section>