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.
- Scroll
- gsap
- scrolltrigger
- svg
- advanced
- medium perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **Zoom-through Hero**. Keep my hero's background image/panel and headline; wrap them in a `.zt__reveal` layer inside a `100vh` `.zt` section with `overflow: hidden`. Over it, add an inline `<svg class="zt__cover">` containing a full-size `<rect>` filled with my page background colour and masked by `<mask id="zt-mask" maskUnits="userSpaceOnUse">` (white rect + black `<text>` of my display word at `x="50%" y="50%"`, `text-anchor="middle"`, `dominant-baseline="central"`, heavy weight, `font-size: clamp(4.5rem, 22vw, 20rem)`). With GSAP ScrollTrigger pin `.zt` (`start: 'top top'`, `end: '+=250%'`, `scrub: 1`, `pinSpacing: true`, `invalidateOnRefresh: true`) and in one scrubbed timeline: scale the mask text 1 → 40 with `ease: 'power2.in'` and `svgOrigin` on the stem of a letter at 0.26 of its advance width (measure an invisible twin `<text>` with `getExtentOfChar`), counter-zoom `.zt__reveal` from `scale 1.3 → 1`, fade the panel `opacity → 0` from 0.8 to 0.94, and rise my headline block (`y 48→0, opacity 0→1, stagger 0.05, power3.out`) from 0.62. Respect `prefers-reduced-motion` by hiding the cover and showing the headline statically. Wrap in `gsap.context()`, revert on unmount, and do not change my fonts or colours. ### EFFECT (React / Next.js): Zoom-through Hero
Build **Zoom-through Hero** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/ZoomThroughHero.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export default function ZoomThroughHero() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="zt-intro">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ZoomThroughHero.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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>
<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>
```
```css
.zt-intro, .zt-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.zt-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.zt-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.zt-intro__title em { font-style: italic; font-weight: 200; }
.zt-intro__copy { max-width: 42ch; margin: 2rem auto 0; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.1rem); line-height: 1.5; }
.zt-outro__text { color: var(--muted); max-width: 40ch; }
.zt { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.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; }
.zt__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: rgba(242,239,233,.6); }
.zt__headline { font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 5.5vw, 5.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 14ch; font-variation-settings: 'opsz' 144, 'SOFT' 30; }
.zt__copy { max-width: 46ch; color: rgba(242,239,233,.72); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; }
.zt__content > * { will-change: transform, opacity; }
/* The cover: a page-coloured panel with the word punched out of it */
.zt__cover { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
.zt__panel { fill: var(--bg); }
.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, 'SOFT' 0; }
.zt__word--measure { fill: none; opacity: 0; pointer-events: none; }
.zt__hint { position: absolute; left: 50%; bottom: 2rem; transform: translateX(-50%); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.zt__cover, .zt__hint { display: none; }
.zt__reveal { will-change: auto; }
}
```
```js
// Zoom-through Hero — scroll scales a masked display word until the camera flies through a letter.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { scaleMax: 40, stemX: 0.26, pinLength: 250, scrub: 1, ease: 'power2.in', glow: '#228ea0' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const FOCUS_CHAR = 1; // index of the character we dive into ("E" of DEEPER)
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.zt');
const reveal = document.querySelector('.zt__reveal');
const canvas = document.querySelector('.zt__canvas');
const cover = document.querySelector('.zt__cover');
const panel = document.querySelector('.zt__panel');
const word = document.querySelector('#zt-mask .zt__word');
const measure = document.querySelector('.zt__word--measure');
const hint = document.querySelector('.zt__hint');
const content = gsap.utils.toArray('.zt__content > *');
const hexToRgb = (hex) => { const n = parseInt(String(hex).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
let stemX = DEFAULTS.stemX; // where the stem sits inside the glyph's advance box (0–1), set per build
/* ---------- procedural "photo": deep-water gradient + grain ---------- */
function paint() {
const dpr = Math.min(devicePixelRatio || 1, 2) * (window.MotionLab?.preview ? 0.5 : 1);
const w = section.clientWidth, h = section.clientHeight;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
const c = canvas.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
c.fillStyle = '#071e2a'; c.fillRect(0, 0, w, h);
const glow = (x, y, r, stops) => {
const g = c.createRadialGradient(x, y, 0, x, y, r);
stops.forEach(([o, col]) => g.addColorStop(o, col));
c.fillStyle = g; c.fillRect(0, 0, w, h);
};
// Key hue (knob): the main glow, with a darker copy of the same hue at the mid stop.
const [r, g, b] = hexToRgb(K('glow'));
const mid = `rgba(${Math.round(r * .41)},${Math.round(g * .55)},${Math.round(b * .625)},.7)`;
glow(w * .62, h * .36, Math.max(w, h) * .7, [[0, `rgba(${r},${g},${b},1)`], [.45, mid], [1, 'rgba(4,20,28,0)']]);
glow(w * .18, h * .85, Math.max(w, h) * .6, [[0, 'rgba(58,50,150,.9)'], [.5, 'rgba(30,26,90,.4)'], [1, 'rgba(4,20,28,0)']]);
glow(w * .8, h * .18, Math.min(w, h) * .32, [[0, 'rgba(212,255,58,.42)'], [.4, 'rgba(160,220,120,.16)'], [1, 'rgba(4,20,28,0)']]);
const band = c.createLinearGradient(0, h * .55, 0, h);
band.addColorStop(0, 'rgba(2,8,14,0)'); band.addColorStop(1, 'rgba(2,8,14,.85)');
c.fillStyle = band; c.fillRect(0, 0, w, h);
// grain: a 192px noise tile repeated at low alpha
const tile = document.createElement('canvas'); tile.width = tile.height = 192;
const tc = tile.getContext('2d'); const img = tc.createImageData(192, 192);
for (let i = 0; i < img.data.length; i += 4) { const v = 128 + (Math.random() - .5) * 255; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
tc.putImageData(img, 0, 0);
c.globalAlpha = .11; c.globalCompositeOperation = 'overlay';
c.fillStyle = c.createPattern(tile, 'repeat'); c.fillRect(0, 0, w, h);
c.globalAlpha = 1; c.globalCompositeOperation = 'source-over';
}
/* ---------- where inside the word do we dive? (the stem of the focus letter) ---------- */
function origin() {
const ext = measure.getExtentOfChar(FOCUS_CHAR);
return `${(ext.x + ext.width * stemX).toFixed(1)} ${(ext.y + ext.height * 0.5).toFixed(1)}`;
}
let ctx;
function build() {
ctx?.revert(); // unpins + removes the spacer, restores the word's transform
stemX = K('stemX');
const scaleMax = K('scaleMax');
const ease = K('ease');
const scrub = K('scrub') === 0 ? true : K('scrub');
const end = `+=${K('pinLength')}%`;
paint();
ctx = gsap.context(() => {
gsap.from('.zt-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) {
// Final state: the reveal panel is simply visible, no pin, no zoom.
gsap.set(cover, { display: 'none' });
gsap.set(hint, { display: 'none' });
gsap.set(content, { opacity: 1, y: 0 });
return;
}
gsap.set(content, { y: 48, opacity: 0 });
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: section, start: 'top top', end,
scrub, pin: true, pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
},
});
tl.to(hint, { opacity: 0, duration: 0.08 }, 0)
.fromTo(word, { scale: 1, svgOrigin: () => origin() }, { scale: scaleMax, svgOrigin: () => origin(), ease, duration: 1 }, 0)
.fromTo(reveal, { scale: 1.3 }, { scale: 1, ease: 'power1.out', duration: 1 }, 0)
.to(panel, { opacity: 0, duration: 0.14 }, 0.8)
.to(content, { y: 0, opacity: 1, duration: 0.3, stagger: 0.05, ease: 'power3.out' }, 0.62);
document.fonts.ready.then(() => { tl.invalidate(); ScrollTrigger.refresh(); });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
let resizeT;
const onResize = () => { clearTimeout(resizeT); resizeT = setTimeout(paint, 120); };
window.addEventListener('resize', onResize);
window.MotionLab?.onCleanup(() => {
window.removeEventListener('resize', onResize);
clearTimeout(resizeT);
ctx?.revert();
});
```
### EFFECT (single HTML file): Zoom-through Hero
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Zoom-through Hero** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zoom-through Hero</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<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>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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>
<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>
```
```css
.zt-intro, .zt-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.zt-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.zt-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.zt-intro__title em { font-style: italic; font-weight: 200; }
.zt-intro__copy { max-width: 42ch; margin: 2rem auto 0; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.1rem); line-height: 1.5; }
.zt-outro__text { color: var(--muted); max-width: 40ch; }
.zt { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.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; }
.zt__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: rgba(242,239,233,.6); }
.zt__headline { font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 5.5vw, 5.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 14ch; font-variation-settings: 'opsz' 144, 'SOFT' 30; }
.zt__copy { max-width: 46ch; color: rgba(242,239,233,.72); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; }
.zt__content > * { will-change: transform, opacity; }
/* The cover: a page-coloured panel with the word punched out of it */
.zt__cover { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
.zt__panel { fill: var(--bg); }
.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, 'SOFT' 0; }
.zt__word--measure { fill: none; opacity: 0; pointer-events: none; }
.zt__hint { position: absolute; left: 50%; bottom: 2rem; transform: translateX(-50%); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.zt__cover, .zt__hint { display: none; }
.zt__reveal { will-change: auto; }
}
```
```js
// Zoom-through Hero — scroll scales a masked display word until the camera flies through a letter.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { scaleMax: 40, stemX: 0.26, pinLength: 250, scrub: 1, ease: 'power2.in', glow: '#228ea0' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const FOCUS_CHAR = 1; // index of the character we dive into ("E" of DEEPER)
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.zt');
const reveal = document.querySelector('.zt__reveal');
const canvas = document.querySelector('.zt__canvas');
const cover = document.querySelector('.zt__cover');
const panel = document.querySelector('.zt__panel');
const word = document.querySelector('#zt-mask .zt__word');
const measure = document.querySelector('.zt__word--measure');
const hint = document.querySelector('.zt__hint');
const content = gsap.utils.toArray('.zt__content > *');
const hexToRgb = (hex) => { const n = parseInt(String(hex).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
let stemX = DEFAULTS.stemX; // where the stem sits inside the glyph's advance box (0–1), set per build
/* ---------- procedural "photo": deep-water gradient + grain ---------- */
function paint() {
const dpr = Math.min(devicePixelRatio || 1, 2) * (window.MotionLab?.preview ? 0.5 : 1);
const w = section.clientWidth, h = section.clientHeight;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
const c = canvas.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
c.fillStyle = '#071e2a'; c.fillRect(0, 0, w, h);
const glow = (x, y, r, stops) => {
const g = c.createRadialGradient(x, y, 0, x, y, r);
stops.forEach(([o, col]) => g.addColorStop(o, col));
c.fillStyle = g; c.fillRect(0, 0, w, h);
};
// Key hue (knob): the main glow, with a darker copy of the same hue at the mid stop.
const [r, g, b] = hexToRgb(K('glow'));
const mid = `rgba(${Math.round(r * .41)},${Math.round(g * .55)},${Math.round(b * .625)},.7)`;
glow(w * .62, h * .36, Math.max(w, h) * .7, [[0, `rgba(${r},${g},${b},1)`], [.45, mid], [1, 'rgba(4,20,28,0)']]);
glow(w * .18, h * .85, Math.max(w, h) * .6, [[0, 'rgba(58,50,150,.9)'], [.5, 'rgba(30,26,90,.4)'], [1, 'rgba(4,20,28,0)']]);
glow(w * .8, h * .18, Math.min(w, h) * .32, [[0, 'rgba(212,255,58,.42)'], [.4, 'rgba(160,220,120,.16)'], [1, 'rgba(4,20,28,0)']]);
const band = c.createLinearGradient(0, h * .55, 0, h);
band.addColorStop(0, 'rgba(2,8,14,0)'); band.addColorStop(1, 'rgba(2,8,14,.85)');
c.fillStyle = band; c.fillRect(0, 0, w, h);
// grain: a 192px noise tile repeated at low alpha
const tile = document.createElement('canvas'); tile.width = tile.height = 192;
const tc = tile.getContext('2d'); const img = tc.createImageData(192, 192);
for (let i = 0; i < img.data.length; i += 4) { const v = 128 + (Math.random() - .5) * 255; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
tc.putImageData(img, 0, 0);
c.globalAlpha = .11; c.globalCompositeOperation = 'overlay';
c.fillStyle = c.createPattern(tile, 'repeat'); c.fillRect(0, 0, w, h);
c.globalAlpha = 1; c.globalCompositeOperation = 'source-over';
}
/* ---------- where inside the word do we dive? (the stem of the focus letter) ---------- */
function origin() {
const ext = measure.getExtentOfChar(FOCUS_CHAR);
return `${(ext.x + ext.width * stemX).toFixed(1)} ${(ext.y + ext.height * 0.5).toFixed(1)}`;
}
let ctx;
function build() {
ctx?.revert(); // unpins + removes the spacer, restores the word's transform
stemX = K('stemX');
const scaleMax = K('scaleMax');
const ease = K('ease');
const scrub = K('scrub') === 0 ? true : K('scrub');
const end = `+=${K('pinLength')}%`;
paint();
ctx = gsap.context(() => {
gsap.from('.zt-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) {
// Final state: the reveal panel is simply visible, no pin, no zoom.
gsap.set(cover, { display: 'none' });
gsap.set(hint, { display: 'none' });
gsap.set(content, { opacity: 1, y: 0 });
return;
}
gsap.set(content, { y: 48, opacity: 0 });
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: section, start: 'top top', end,
scrub, pin: true, pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
},
});
tl.to(hint, { opacity: 0, duration: 0.08 }, 0)
.fromTo(word, { scale: 1, svgOrigin: () => origin() }, { scale: scaleMax, svgOrigin: () => origin(), ease, duration: 1 }, 0)
.fromTo(reveal, { scale: 1.3 }, { scale: 1, ease: 'power1.out', duration: 1 }, 0)
.to(panel, { opacity: 0, duration: 0.14 }, 0.8)
.to(content, { y: 0, opacity: 1, duration: 0.3, stagger: 0.05, ease: 'power3.out' }, 0.62);
document.fonts.ready.then(() => { tl.invalidate(); ScrollTrigger.refresh(); });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
let resizeT;
const onResize = () => { clearTimeout(resizeT); resizeT = setTimeout(paint, 120); };
window.addEventListener('resize', onResize);
window.MotionLab?.onCleanup(() => {
window.removeEventListener('resize', onResize);
clearTimeout(resizeT);
ctx?.revert();
});
```
### EFFECT (Framer & Webflow notes): Zoom-through Hero
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withZoomthroughHero(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `zt-intro`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<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>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.zt-intro, .zt-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.zt-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.zt-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 10vw, 10rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.zt-intro__title em { font-style: italic; font-weight: 200; }
.zt-intro__copy { max-width: 42ch; margin: 2rem auto 0; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.1rem); line-height: 1.5; }
.zt-outro__text { color: var(--muted); max-width: 40ch; }
.zt { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.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; }
.zt__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: rgba(242,239,233,.6); }
.zt__headline { font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 5.5vw, 5.5rem); letter-spacing: -0.04em; line-height: .95; max-width: 14ch; font-variation-settings: 'opsz' 144, 'SOFT' 30; }
.zt__copy { max-width: 46ch; color: rgba(242,239,233,.72); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; }
.zt__content > * { will-change: transform, opacity; }
/* The cover: a page-coloured panel with the word punched out of it */
.zt__cover { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
.zt__panel { fill: var(--bg); }
.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, 'SOFT' 0; }
.zt__word--measure { fill: none; opacity: 0; pointer-events: none; }
.zt__hint { position: absolute; left: 50%; bottom: 2rem; transform: translateX(-50%); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.zt__cover, .zt__hint { display: none; }
.zt__reveal { will-change: auto; }
}
// Zoom-through Hero — scroll scales a masked display word until the camera flies through a letter.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { scaleMax: 40, stemX: 0.26, pinLength: 250, scrub: 1, ease: 'power2.in', glow: '#228ea0' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const FOCUS_CHAR = 1; // index of the character we dive into ("E" of DEEPER)
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.zt');
const reveal = document.querySelector('.zt__reveal');
const canvas = document.querySelector('.zt__canvas');
const cover = document.querySelector('.zt__cover');
const panel = document.querySelector('.zt__panel');
const word = document.querySelector('#zt-mask .zt__word');
const measure = document.querySelector('.zt__word--measure');
const hint = document.querySelector('.zt__hint');
const content = gsap.utils.toArray('.zt__content > *');
const hexToRgb = (hex) => { const n = parseInt(String(hex).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
let stemX = DEFAULTS.stemX; // where the stem sits inside the glyph's advance box (0–1), set per build
/* ---------- procedural "photo": deep-water gradient + grain ---------- */
function paint() {
const dpr = Math.min(devicePixelRatio || 1, 2) * (window.MotionLab?.preview ? 0.5 : 1);
const w = section.clientWidth, h = section.clientHeight;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
const c = canvas.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
c.fillStyle = '#071e2a'; c.fillRect(0, 0, w, h);
const glow = (x, y, r, stops) => {
const g = c.createRadialGradient(x, y, 0, x, y, r);
stops.forEach(([o, col]) => g.addColorStop(o, col));
c.fillStyle = g; c.fillRect(0, 0, w, h);
};
// Key hue (knob): the main glow, with a darker copy of the same hue at the mid stop.
const [r, g, b] = hexToRgb(K('glow'));
const mid = `rgba(${Math.round(r * .41)},${Math.round(g * .55)},${Math.round(b * .625)},.7)`;
glow(w * .62, h * .36, Math.max(w, h) * .7, [[0, `rgba(${r},${g},${b},1)`], [.45, mid], [1, 'rgba(4,20,28,0)']]);
glow(w * .18, h * .85, Math.max(w, h) * .6, [[0, 'rgba(58,50,150,.9)'], [.5, 'rgba(30,26,90,.4)'], [1, 'rgba(4,20,28,0)']]);
glow(w * .8, h * .18, Math.min(w, h) * .32, [[0, 'rgba(212,255,58,.42)'], [.4, 'rgba(160,220,120,.16)'], [1, 'rgba(4,20,28,0)']]);
const band = c.createLinearGradient(0, h * .55, 0, h);
band.addColorStop(0, 'rgba(2,8,14,0)'); band.addColorStop(1, 'rgba(2,8,14,.85)');
c.fillStyle = band; c.fillRect(0, 0, w, h);
// grain: a 192px noise tile repeated at low alpha
const tile = document.createElement('canvas'); tile.width = tile.height = 192;
const tc = tile.getContext('2d'); const img = tc.createImageData(192, 192);
for (let i = 0; i < img.data.length; i += 4) { const v = 128 + (Math.random() - .5) * 255; img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255; }
tc.putImageData(img, 0, 0);
c.globalAlpha = .11; c.globalCompositeOperation = 'overlay';
c.fillStyle = c.createPattern(tile, 'repeat'); c.fillRect(0, 0, w, h);
c.globalAlpha = 1; c.globalCompositeOperation = 'source-over';
}
/* ---------- where inside the word do we dive? (the stem of the focus letter) ---------- */
function origin() {
const ext = measure.getExtentOfChar(FOCUS_CHAR);
return `${(ext.x + ext.width * stemX).toFixed(1)} ${(ext.y + ext.height * 0.5).toFixed(1)}`;
}
let ctx;
function build() {
ctx?.revert(); // unpins + removes the spacer, restores the word's transform
stemX = K('stemX');
const scaleMax = K('scaleMax');
const ease = K('ease');
const scrub = K('scrub') === 0 ? true : K('scrub');
const end = `+=${K('pinLength')}%`;
paint();
ctx = gsap.context(() => {
gsap.from('.zt-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) {
// Final state: the reveal panel is simply visible, no pin, no zoom.
gsap.set(cover, { display: 'none' });
gsap.set(hint, { display: 'none' });
gsap.set(content, { opacity: 1, y: 0 });
return;
}
gsap.set(content, { y: 48, opacity: 0 });
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: {
trigger: section, start: 'top top', end,
scrub, pin: true, pinSpacing: true, anticipatePin: 1, invalidateOnRefresh: true, markers: false,
},
});
tl.to(hint, { opacity: 0, duration: 0.08 }, 0)
.fromTo(word, { scale: 1, svgOrigin: () => origin() }, { scale: scaleMax, svgOrigin: () => origin(), ease, duration: 1 }, 0)
.fromTo(reveal, { scale: 1.3 }, { scale: 1, ease: 'power1.out', duration: 1 }, 0)
.to(panel, { opacity: 0, duration: 0.14 }, 0.8)
.to(content, { y: 0, opacity: 1, duration: 0.3, stagger: 0.05, ease: 'power3.out' }, 0.62);
document.fonts.ready.then(() => { tl.invalidate(); ScrollTrigger.refresh(); });
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
let resizeT;
const onResize = () => { clearTimeout(resizeT); resizeT = setTimeout(paint, 120); };
window.addEventListener('resize', onResize);
window.MotionLab?.onCleanup(() => {
window.removeEventListener('resize', onResize);
clearTimeout(resizeT);
ctx?.revert();
});