---
name: scroll-based-zoom
description: Use when you want "Dramatic, immersive, modern" - Zooms images, text, or scenes as the user scrolls.
---

# Scroll-Based Zoom

> **Category:** Scroll  -  **Personality:** Dramatic, immersive, modern
>
> **Best use:** Hero transitions, galleries
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

Scroll-Based Zoom **pins a hero scene to the viewport and magnifies it as the reader scrolls**, turning a single image into a slow cinematic push-in. A tall "track" element supplies the scroll runway; a `position: sticky` stage stays fixed inside it while the scroll distance is converted into a `0 → 1` progress value, and that value drives `transform: scale()` on the image (plus a focal-point reframe and crossfading captions).

Because the zoom is bound to scroll *position* rather than to a clock, the viewer controls the pace — scrolling up rewinds the shot exactly. It reads as dramatic and immersive, which is why it suits opening hero transitions, title sequences and editorial photo galleries. The demo dresses it as a one-shot expedition film ("Meridian"), pushing into a golden-hour ridge while a timecode and elevation read-out climb and three caption "chapters" hand off.

## Dependencies / CDN

**None - vanilla JS + CSS.** No scroll library is required; the engine is a `requestAnimationFrame`-throttled `scroll` listener. The demo only loads display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
```

Fonts: **Anton** (cinematic poster display), **Hanken Grotesk** (UI/body), **JetBrains Mono** (HUD data: timecode, coordinates, elevation).

## HTML

A tall **track** holds a **sticky pin**; the pin centres the framed **stage**; the stage contains the zooming `<img>`, a scrim, the captions and a progress scrubber. (Captions trimmed to one below — the demo stacks three.)

```html
<div class="sz-track">                 <!-- scroll runway: ~300vh tall -->
  <div class="sz-pin">                 <!-- position:sticky; top:0; height:100vh -->
    <section class="sz-stage">         <!-- the framed, rounded, clipped panel -->
      <img class="sz-img" src="/img/landscape-mountain.jpg"
           alt="Golden-hour light across a layered mountain range">
      <div class="sz-scrim" aria-hidden="true"></div>

      <article class="sz-chapter sz-final">
        <p class="sz-eyebrow">2,810 m &middot; 06:14</p>
        <h2 class="sz-title">The<em>Summit</em></h2>
        <p class="sz-sub">Twelve hundred metres of climb, in one unbroken scroll.</p>
      </article>

      <div class="sz-progress" aria-hidden="true"><span class="sz-progfill"></span></div>
    </section>
  </div>
</div>
```

## CSS

The three rules that *make* the effect are `.sz-track` (runway), `.sz-pin` (sticky), and `.sz-img` (the thing that scales). Everything else is dressing.

```css
/* 1) RUNWAY + PIN — the mechanism */
.sz-track{position:relative; height:300vh}            /* taller than the pin = scroll distance */
.sz-pin{position:sticky; top:0; height:100vh;         /* holds the stage in view while we scroll */
  display:flex; align-items:center; justify-content:center}
@supports(height:100svh){                              /* mobile-safe units */
  .sz-track{height:300svh} .sz-pin{height:100svh}}

/* 2) FRAMED STAGE — rounded, clipped panel (clips the zoom) */
.sz-stage{position:relative; width:100%; height:min(86vh,760px);
  border-radius:26px; overflow:hidden; background:#06080c; isolation:isolate}

/* 3) THE SCENE THAT ZOOMS — JS writes transform:scale(...) here */
.sz-img{position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
  transform-origin:72% 54%;          /* focal point: push toward the lit peak  */
  transform:scale(1);                /* scale stays >= 1 so edges never reveal  */
  will-change:transform}

/* readability + a bottom film-scrubber */
.sz-scrim{position:absolute; inset:0; pointer-events:none; background:
  linear-gradient(to top,rgba(6,8,12,.94) 1%,rgba(6,8,12,.30) 32%,rgba(6,8,12,0) 58%),
  linear-gradient(to right,rgba(6,8,12,.74),rgba(6,8,12,0) 50%)}
.sz-progress{position:absolute; left:0; right:0; bottom:0; height:3px; background:rgba(255,255,255,.13)}
.sz-progfill{display:block; height:100%; transform-origin:left; transform:scaleX(0);
  background:linear-gradient(90deg,#ff9d2f,#ffe3b0)}

/* captions crossfade (opacity driven per-frame by JS) */
.sz-chapter{position:absolute; left:46px; right:46px; bottom:clamp(44px,9vh,88px);
  opacity:0; pointer-events:none; will-change:opacity,transform}

/* REDUCED MOTION — collapse the runway to a single static framed hero */
@media (prefers-reduced-motion:reduce){
  .sz-track{height:auto}
  .sz-pin{position:relative; height:auto}
  .sz-stage{height:min(74vh,620px)}
  .sz-img{transform:scale(1.16)}     /* a pleasant fixed crop, no scrubbing */
  .sz-final{opacity:1}               /* show one summary caption            */
  .sz-progress{display:none}
}
```

## JavaScript

Map the track's position through the viewport to `0 → 1`, then write `scale()` on the image and `scaleX()` on the scrubber every animation frame. Captions get a "hold window" each so one reads at a time and they crossfade at the seams.

```js
(function(){
  var track = document.querySelector('.sz-track'),
      img   = document.querySelector('.sz-img');
  if(!track || !img) return;
  // Reduced motion: skip the engine — CSS shows a calm static framed hero.
  if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  var chapters = [].slice.call(document.querySelectorAll('.sz-chapter')),
      fill = document.querySelector('.sz-progfill');

  var MINS = 1, MAXS = 1.95,                 // zoom range
      BANDS = [[0,.22],[.40,.60],[.80,1]],   // per-caption hold windows
      FADE = .12;

  var clamp = function(v,a,b){return v<a?a:v>b?b:v;};
  var lerp  = function(a,b,t){return a+(b-a)*t;};
  var smooth= function(t){return t*t*(3-2*t);};   // smoothstep easing

  var ticking = false, last = -1;
  function render(){
    ticking = false;
    var r = track.getBoundingClientRect(),
        dist = r.height - window.innerHeight,       // scrollable distance while pinned
        p = dist > 0 ? clamp(-r.top / dist, 0, 1) : 0;
    if(p === last) return; last = p;

    img.style.transform = 'scale(' + lerp(MINS, MAXS, smooth(p)).toFixed(4) + ')';
    if(fill) fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';

    for(var i=0;i<chapters.length;i++){
      var b = BANDS[i] || [i/chapters.length,(i+1)/chapters.length],
          o = clamp(Math.min((p-(b[0]-FADE))/FADE, (b[1]+FADE-p)/FADE, 1), 0, 1);
      chapters[i].style.opacity = o.toFixed(3);
      chapters[i].style.transform = 'translate3d(0,' + ((1-o)*20).toFixed(1) + 'px,0)';
    }
  }
  // Throttle scroll/resize through one rAF tick — never compute mid-frame.
  function onScroll(){ if(!ticking){ ticking = true; requestAnimationFrame(render); } }
  addEventListener('scroll', onScroll, {passive:true});
  addEventListener('resize', onScroll, {passive:true});
  render();
})();
```

## How it works

- **Sticky pin = the magic.** The `.sz-pin` is `position:sticky; top:0; height:100vh`, nested in a `.sz-track` that is `300vh` tall. While the 200vh of "extra" track scrolls past, the pin stays glued to the top of the viewport — that frozen window is what makes the zoom feel like a held camera shot.
- **Progress from geometry.** `getBoundingClientRect().top` of the track goes from `0` (track top hits viewport top) down to `-(trackHeight - viewportHeight)`. Dividing the negated top by that distance yields a clean `0 → 1` no matter the viewport size — no hard-coded pixels.
- **Only `transform` moves.** The image is scaled (`scale(1 → 1.95)`); a `transform-origin` of `72% 54%` parks the growth on the lit peak so the zoom also *reframes*. Scale never drops below `1`, so the clipped stage is always fully covered (no exposed edges).
- **Smoothstep** (`t*t*(3-2*t)`) eases the scale so it eases in and settles instead of tracking the wheel linearly.
- **Captions hand off** via per-caption `[start,end]` "hold windows" with a 12% fade on each side, so exactly one chapter reads at a time and adjacent ones crossfade. The bottom scrubber and the (demo) timecode/elevation read-outs are just `lerp`s of the same `p`.

## Customization

- **Intensity:** raise `MAXS` for a more aggressive push-in (`2.4`+ gets extreme); lower it (`1.3`) for a subtle drift.
- **Pacing / length:** change `.sz-track` height. Taller (`400vh`) = slower, more deliberate; shorter (`200vh`) = snappier. The JS needs no change — distance is derived.
- **Focal point:** move `transform-origin` to aim the zoom (e.g. `50% 50%` centre, `30% 70%` lower-left). Keep `scale >= 1` or you must also translate to avoid gaps.
- **Native-CSS alternative:** in evergreen browsers you can drop the JS entirely with scroll-driven animations — `animation-timeline: view()` / `scroll()` plus a `@keyframes` that scales the image. Treat it as progressive enhancement (Safari/older browsers lack it) and keep this listener as the fallback.
- **Content:** swap the image, rewrite the chapters, or pin text/SVG instead of a photo. Add chapters by extending `BANDS` with new windows.

## Accessibility & performance

- **Honour `prefers-reduced-motion`.** The JS returns early and a `@media` block collapses the `300vh` runway to a single static framed hero at a fixed `scale(1.16)` showing one summary caption — no surprise scroll-jacking, no motion.
- **Cheap frames.** Work is throttled to one `requestAnimationFrame` per scroll burst, listeners are `{passive:true}`, and only compositor-friendly `transform`/`opacity` change (never `width`/`height`/`top`) — so it stays on the GPU and avoids layout thrash.
- **Real semantics.** The hero is a real `<img>` with descriptive `alt`; decorative scrim/grain/HUD layers are `aria-hidden`. Captions are real `<h2>`/`<p>` text, so the story is readable without the effect.
- **Legibility:** a multi-stop scrim seats the text over the bright photo; verify contrast against the *brightest* zoom frame, not just the start.

## Gotchas

- **Sticky dies inside `overflow`.** If *any* ancestor of the pin has `overflow: hidden/auto/scroll` (or a `transform`/`filter` that creates a containing block), `position:sticky` silently falls back to static and nothing pins. Keep the track/pin in normal page flow.
- **The track must be taller than the pin.** If `.sz-track` height ≤ viewport height, `dist <= 0` and there's no scroll distance to map — guard with `dist > 0` (done) or the scene never zooms.
- **`100vh` lies on mobile.** Address-bar show/hide makes `100vh` jump and can cause a layout shift mid-scroll; prefer `svh` (`@supports(height:100svh)`) for the pin and track.
- **Edge reveal.** Scaling *below* 1, or scaling up from an origin while also translating too far, exposes the stage background at the image edges. Keep `scale >= 1` (or compensate with translate) and clip with `overflow:hidden` on the stage.
- **Don't animate the blur/size.** Animating `width`/`height`/`filter` per frame tanks performance; scale a `transform` instead.
- **iOS sticky + `will-change`.** Over-using `will-change` on many layers can blow GPU memory; keep it on the image and the moving captions only (as here).
