---
name: pinning-scroll-animation
description: Use when you want "Advanced, cinematic, premium" - Pins elements while scroll controls animation progress.
---

# Pinning Scroll Animation

> **Category:** Scroll  -  **Personality:** Advanced, cinematic, premium
>
> **Best use:** High-end landing pages
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 4/5

## What it is

A pinning scroll animation **freezes one element in the viewport** while the page keeps scrolling, and **maps that scroll distance onto an animation timeline** (a "scrubbed" timeline). Instead of the page sliding past, the pinned stage stays put and its contents morph — the scrollbar becomes a scrubber the visitor drags. It's the signature move of high-end product launches (Apple, premium audio/auto brands): one fixed canvas that tells a multi-step story as you scroll. The demo pins a product "stage" and scrubs a four-act reveal of the *VANTÁ Eclipse One* headphones — name → engineering specs → a counting precision stat → the offer — with the product tilting and detail-zooming between acts.

## Dependencies / CDN

GSAP + its **ScrollTrigger** plugin do the pinning and scroll-scrubbing (pinned exact versions, with SRI):

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js" integrity="sha384-g4NTh/Iv5PPU4xPyhEWqPcwtNXOvdaDI8LLnyYfyNZOjKJeYQyjzQ9X5275eBjpt" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js" integrity="sha384-Z3REaz79l2IaAZqJsSABtTbhjgOUYyV3p90XNnAPCSHg3EMTz1fouunq9WZRtj3d" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
```

Fonts (optional — Unbounded display, Hanken Grotesk body, JetBrains Mono accents):

```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=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Unbounded:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

One pinned stage; one absolutely-stacked `.psa-layer` per "act"; one persistent product the timeline transforms:

```html
<section class="psa-track">
  <div class="psa-stage">                          <!-- THIS element gets pinned -->
    <div class="psa-inner">
      <div class="psa-textwrap">
        <div class="psa-layer psa-act1"> … the name … </div>
        <ul  class="psa-layer psa-specs"> … the specs … </ul>
        <div class="psa-layer psa-stat">
          <p class="psa-stat-v"><span data-psa-count>0</span> <i>Hz</i></p>
          <div class="psa-meter"><span data-psa-meter></span></div>
        </div>
        <div class="psa-layer psa-cta"> … the offer … </div>
      </div>
      <figure class="psa-product">
        <img src="/front-end-design-effects-catalog/assets/img/product-headphones.jpg" alt="Eclipse One headphones">
      </figure>
    </div>
    <div class="psa-rail"><span class="psa-rail-fill" data-psa-rail></span></div>
  </div>
</section>
```

## CSS

```css
/* The pinned stage = exactly one viewport tall, clipped, so it reads as a framed canvas */
.psa-stage{position:relative; overflow:hidden; border-radius:22px;
  height:100vh; height:100svh; min-height:560px;
  display:flex; flex-direction:column}

/* Every act occupies the same box; the timeline cross-fades between them */
.psa-layer{position:absolute; inset:0; display:flex; flex-direction:column;
  justify-content:center; pointer-events:none; will-change:opacity,transform}

/* The scrub progress rail — JS drives scaleX 0 -> 1 from ScrollTrigger.progress */
.psa-rail{height:2px; background:rgba(255,255,255,.12); overflow:hidden; border-radius:999px}
.psa-rail-fill{display:block; height:100%; transform:scaleX(0); transform-origin:left;
  background:linear-gradient(90deg,#7fd0ff,#b79bff,#ffd27f)}

/* Reduced-motion / no-JS fallback: drop the pin, let every act stack & show statically */
.psa-track.is-static .psa-stage{height:auto; min-height:0; display:block}
.psa-track.is-static .psa-layer{position:relative; inset:auto; opacity:1!important;
  transform:none!important; margin-bottom:36px}
.psa-track.is-static .psa-meter span{width:100%}
```

## JavaScript

```js
const track  = document.querySelector('.psa-track');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

// No motion wanted, or GSAP failed to load → show the whole story at once, static.
if (reduce || !window.gsap || !window.ScrollTrigger) {
  track.classList.add('is-static');
} else {
  gsap.registerPlugin(ScrollTrigger);

  // progress 0 = Act 1 composed; everything else starts hidden/offset
  gsap.set('.psa-spec', {opacity:0, xPercent:30});
  gsap.set('.psa-stat', {opacity:0, y:28});
  gsap.set('.psa-cta',  {opacity:0, y:40, scale:.96});

  const railFill = track.querySelector('[data-psa-rail]');
  const countEl  = track.querySelector('[data-psa-count]');
  const counter  = {v:0};

  const tl = gsap.timeline({
    defaults:{ease:'power2.inOut'},
    scrollTrigger:{
      trigger:'.psa-stage',
      start:'top top',
      end:'+=3400',     // how far you scroll while it stays pinned
      pin:true,         // freeze the stage in the viewport…
      scrub:1,          // …and tie timeline progress to scroll (1 = 1s catch-up)
      anticipatePin:1,
      invalidateOnRefresh:true,
      onUpdate:self => { railFill.style.transform = `scaleX(${self.progress})`; }
    }
  });

  // ACT 1 → 2 : name out, product presents, specs stagger in
  tl.to('.psa-act1',     {opacity:0, y:-34, duration:1},                 1.2)
    .to('.psa-product',  {scale:.86, xPercent:-4, rotate:-2.5, duration:1.8}, 1.2)
    .to('.psa-product img', {scale:1.12, duration:1.8},                  1.2)
    .to('.psa-spec',     {opacity:1, xPercent:0, stagger:.16, duration:.8}, 2.1)
  // ACT 2 → 3 : specs out, product detail-zoom, headline stat counts up
    .to('.psa-spec',     {opacity:0, xPercent:-12, stagger:.08, duration:.7}, 4.0)
    .to('.psa-product',  {scale:.68, rotate:3.5, xPercent:6, duration:1.6}, 4.2)
    .to('.psa-product img', {scale:1.24, duration:1.6},                  4.2)
    .to('.psa-stat',     {opacity:1, y:0, duration:1},                   4.8)
    .to(counter, {v:40000, duration:1.7, ease:'power1.out',
        onUpdate:() => countEl.textContent = Math.round(counter.v).toLocaleString('en-US')}, 4.8)
    .to('.psa-meter span', {width:'100%', duration:1.7, ease:'power1.out'}, 4.8)
  // ACT 3 → 4 : stat out, product settles centre, offer in
    .to('.psa-stat',     {opacity:0, y:-28, duration:.8},                7.0)
    .to('.psa-product',  {scale:.8, rotate:0, xPercent:0, yPercent:-3, duration:1.6}, 7.2)
    .to('.psa-cta',      {opacity:1, y:0, scale:1, duration:1.1},        7.8)
    .to({}, {duration:1});   // tail hold so Act 4 lingers before unpin

  addEventListener('load', () => ScrollTrigger.refresh()); // recalc after fonts/images load
}
```

## How it works

- **`pin:true`** tells ScrollTrigger to take the stage out of flow and `position:fixed` it the moment its top hits the start (`top top`), then release it once the trigger ends. ScrollTrigger automatically inserts a "pin-spacer" of `end` pixels (here 3400px) so the rest of the page doesn't jump — the page is still scrolling, the stage just isn't moving.
- **`scrub:1`** links the timeline's playhead to scroll position instead of real time: scroll forward and the timeline plays forward, scroll back and it rewinds. The `1` adds a one-second eased catch-up so the motion feels weighty rather than glued to the wheel.
- **Position parameters** (`1.2`, `4.2`, `7.8` …) place each tween on a shared time ruler, so acts overlap and hand off cleanly. Because scrub maps the whole timeline across the `end` distance, those numbers are effectively "scroll chapters."
- **A proxy-object count-up** (`gsap.to(counter, {v:40000, onUpdate})`) is scrubbed too, so the frequency number ticks up and back down as you drag — the scroll literally drives the data.
- **`onUpdate` → `self.progress`** (0→1) feeds the bottom rail (`scaleX`) and the step dots, making the pin↔scroll relationship legible.

## Customization

- **Pace:** lengthen/shorten `end` (`'+=3400'`). Bigger = slower, more deliberate cinematic feel; smaller = snappier.
- **Feel of the scrub:** `scrub:true` welds motion to the scrollbar (1:1, no lag); `scrub:1`–`2` adds smoothing. Raise it for a more "filmic" glide.
- **Add/remove acts:** each act is just a `.psa-layer` plus a pair of in/out tweens at new position values. Keep the in-point of act *n+1* near the out-point of act *n*.
- **Re-theme:** the three ambient `.psa-glow` blobs are cross-faded per act (cool → violet → amber) to shift the mood; swap the colours or the product image to fit any brand.
- **Pin point:** `start:'top top'` pins flush to the top; use `'top center'` to pin when the stage reaches mid-viewport.

## Accessibility & performance

- **Reduced motion is a hard branch, not a tweak:** if `prefers-reduced-motion: reduce` (or GSAP is unavailable), the code never creates the ScrollTrigger — it adds `.is-static`, which un-pins the stage and stacks every act in normal flow so the *entire* story is readable by plain scrolling. No hijacked scroll, no surprise movement.
- Animate only **`transform` and `opacity`** (all tweens here do) so each frame stays on the compositor; `will-change` is set on the moving layers. The blurred glow blobs are the one expensive part — keep their count low.
- Pinning changes nothing about tab order or the DOM, so keyboard/`Tab` reach the CTA button normally; the rail and step dots are `aria-hidden` decoration.
- Call `ScrollTrigger.refresh()` on `load` (and on resize, which the plugin does itself) so measurements taken before web-fonts/images settle don't leave the pin starting a few pixels off.

## Gotchas

- **A transformed ancestor breaks `position:fixed` pinning.** If any parent of the stage has its own `transform`, `filter`, `perspective`, or `will-change:transform`, the browser makes it the containing block and the pin "drifts" or jitters. Keep the pinned stage free of transformed ancestors (ScrollTrigger warns about this in the console).
- **The pinned element must be one viewport tall (or shorter).** A stage taller than `100svh` gets its bottom clipped while pinned — size content with `clamp()` and `overflow:hidden` so it always fits.
- **`end:'+=N'` is a scroll distance, not a CSS length** — it's the number of pixels the user scrolls while pinned. Forgetting it (or leaving `pinSpacing:false`) makes the next section slide *over* the pinned stage.
- **Measure after load.** Lazy-loaded hero images or late web-fonts change layout height; pin offsets calculated too early start wrong. Refresh on `load` (and use `invalidateOnRefresh:true` so `+=` values recompute on resize).
- **Don't confuse it with a "Card Stack" scroll** — here ONE element is pinned and its *contents* are scrubbed; a card stack pins/sticks several panels that overlap as you pass each one.
