---
name: sticky-section-reveal
description: Use when you want "Premium, narrative, polished" - Keeps content fixed while new content reveals around it.
---

# Sticky Section Reveal

> **Category:** Scroll  -  **Personality:** Premium, narrative, polished
>
> **Best use:** Product explainers, portfolios
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A **scrollytelling** layout: one visual is pinned with `position: sticky` so it stays in view while a column of captions/steps scrolls past beside it. As each step reaches the middle of the viewport it becomes "active", and the pinned visual reacts — here a focus ring travels to the part of the product being described, the caption, tag and step counter update, and a progress bar fills. It is the standard way to walk someone through a product or case study one beat at a time, and reads as premium and narrative because the eye never loses the subject. Pure layout + a tiny bit of `IntersectionObserver` glue — no scroll-jacking library required.

## Dependencies / CDN

**None — pure CSS layout + vanilla JS.** No GSAP/ScrollTrigger, no smooth-scroll hijacking. The demo only loads two display/body 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=Hanken+Grotesk:wght@400;500;600;700;800&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

Two columns inside one track: the sticky visual, and the list of steps that scroll past. (Steps 3–4 trimmed for brevity — just repeat the `<li>`.)

```html
<div class="ssr" data-active="1">
  <div class="ssr-track">

    <!-- STAYS PUT -->
    <div class="ssr-sticky">
      <figure class="ssr-visual">
        <img class="ssr-img" src="/img/product-headphones.jpg" alt="Aura Pro headphones">
        <span class="ssr-focus" aria-hidden="true"></span>           <!-- travelling ring -->
        <span class="ssr-count" aria-hidden="true"><b data-count>01</b><span>/ 04</span></span>
        <figcaption class="ssr-vcap">
          <span class="ssr-vcap-k">Now exploring</span>
          <span class="ssr-vcap-t" data-cap>Feed-forward ANC mics</span>
          <span class="ssr-prog" aria-hidden="true"><i class="ssr-prog-fill" data-prog></i></span>
        </figcaption>
      </figure>
    </div>

    <!-- REVEALS as you scroll -->
    <ol class="ssr-steps">
      <li class="ssr-step is-active" data-step="1">
        <h3 class="ssr-h">It listens, <em>so you don't have to.</em></h3>
        <p class="ssr-p">Eight microphones read the room 1,200 times a second…</p>
      </li>
      <li class="ssr-step" data-step="2">
        <h3 class="ssr-h">Detail you can <em>almost touch.</em></h3>
        <p class="ssr-p">Custom 40 mm beryllium drivers and spatial audio…</p>
      </li>
      <!-- …data-step="3", data-step="4" … -->
    </ol>

  </div>
</div>
```

## CSS

The whole effect is **two rules**: make the visual `sticky`, and give the steps column enough height to scroll. Everything else is theming.

```css
/* 1) The pinned visual. align-self:start (or align-items:start on the grid)
   is REQUIRED — otherwise the grid stretches this item to the full row height
   and it has no slack to "stick" against. */
.ssr-track{display:grid; grid-template-columns:minmax(0,.92fr) minmax(0,1.08fr);
  gap:clamp(26px,5vw,76px); align-items:start}
.ssr-sticky{position:sticky; top:86px; align-self:start}

/* 2) Tall steps = the scroll distance the visual stays pinned for. */
.ssr-steps{list-style:none; margin:0; padding:7vh 0 3vh}
.ssr-step{min-height:74vh; display:flex; flex-direction:column; justify-content:center;
  opacity:.3; transform:translateY(24px); transition:opacity .6s ease, transform .6s ease}
.ssr-step.is-active{opacity:1; transform:none}          /* JS toggles this */

/* The travelling focus ring — JS sets left/top %, CSS animates the move. */
.ssr-visual{position:relative; aspect-ratio:1/1; border-radius:22px; overflow:hidden}
.ssr-img{width:100%; height:100%; object-fit:cover}
.ssr-focus{position:absolute; left:32%; top:64%; width:94px; height:94px; margin:-47px;
  border-radius:50%; border:2px solid rgba(255,255,255,.92);
  box-shadow:0 0 0 6px rgba(255,255,255,.12), 0 0 26px rgba(255,255,255,.4);
  transition:left .7s cubic-bezier(.22,1,.36,1), top .7s cubic-bezier(.22,1,.36,1)}

/* Horizontal progress fill (transform = cheap/GPU). */
.ssr-prog{position:absolute; left:0; right:0; bottom:0; height:3px; background:rgba(255,255,255,.16)}
.ssr-prog-fill{display:block; height:100%; transform-origin:left; transform:scaleX(0)}

/* Static, fully-legible fallback. */
@media (prefers-reduced-motion:reduce){
  .ssr-step{opacity:1; transform:none}
  .ssr-focus,.ssr-img,.ssr-prog-fill{transition:none}
}
```

## JavaScript

`IntersectionObserver` decides which step is active (the one crossing the central band); a rAF-coalesced scroll handler fills the progress bar (skipped entirely under reduced motion).

```js
var ssr = document.querySelector('.ssr');
var steps = [].slice.call(ssr.querySelectorAll('.ssr-step'));
var focus = ssr.querySelector('.ssr-focus'),
    cap = ssr.querySelector('[data-cap]'),
    count = ssr.querySelector('[data-count]'),
    prog = ssr.querySelector('[data-prog]'),
    wrap = ssr.querySelector('.ssr-steps');
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

// what each step points at on the image (x/y %) + its accent
var DATA = [
  {x:32,y:64, cap:'Feed-forward ANC mics',  glow:'#ff8f6b'},
  {x:68,y:60, cap:'40 mm beryllium driver', glow:'#ffa24d'},
  {x:50,y:17, cap:'Memory-foam headband',   glow:'#ff7a9c'},
  {x:64,y:82, cap:'USB-C fast charge',       glow:'#ffc24d'}
];
var total = DATA.length, current = 0;

function setActive(n){
  if(n === current || n < 1 || n > total) return;
  current = n;
  var d = DATA[n-1];
  ssr.setAttribute('data-active', n);
  ssr.style.setProperty('--ssr-glow', d.glow);     // tint the backdrop
  focus.style.left = d.x + '%'; focus.style.top = d.y + '%';
  cap.textContent = d.cap;
  count.textContent = ('0' + n).slice(-2);
  steps.forEach(function(s){ s.classList.toggle('is-active', +s.dataset.step === n); });
  if(reduce) prog.style.transform = 'scaleX(' + (n/total) + ')'; // stepwise, no loop
}

// active = the step crossing the middle 10% of the viewport
var io = new IntersectionObserver(function(entries){
  entries.forEach(function(e){ if(e.isIntersecting) setActive(+e.target.dataset.step); });
}, { rootMargin: '-45% 0px -45% 0px', threshold: 0 });
steps.forEach(function(s){ io.observe(s); });

// smooth progress bar — only when motion is allowed
if(!reduce){
  var ticking = false;
  function update(){
    ticking = false;
    var r = wrap.getBoundingClientRect(), vh = innerHeight, span = r.height - vh*0.5;
    var p = span > 0 ? (vh*0.5 - r.top) / span : 0;
    prog.style.transform = 'scaleX(' + Math.max(0, Math.min(1, p)) + ')';
  }
  addEventListener('scroll', function(){ if(!ticking){ ticking = true; requestAnimationFrame(update); } }, {passive:true});
  update();
}
setActive(1);
```

## How it works

- **`position: sticky; top: 86px`** keeps the visual glued 86 px below the top of the viewport. It stays stuck as long as its parent (the grid cell, which is as tall as the steps column) is still scrolling through — then releases at the bottom. No JS is involved in the pinning itself; it's native layout that follows normal page scroll.
- **Tall steps = the runway.** Each `.ssr-step` is `min-height:74vh`, so there's roughly one screen of scroll per beat. That gap is what gives the pinned visual something to stay pinned *for*.
- **`IntersectionObserver` with `rootMargin:'-45% 0px -45% 0px'`** shrinks the observer's root to a thin band across the viewport's middle. A step "intersects" only while it's centred, so exactly one is active at a time. That fires `setActive()`, which moves the ring (`left/top %`), swaps the caption/counter, retints the backdrop, and toggles `.is-active` (the fade-up).
- **Progress** is just `transform: scaleX()` driven by how far the steps block has travelled past the viewport centre — coalesced into one `requestAnimationFrame` per scroll burst.

## Customization

- **Which side pins:** swap the grid column order, or put `.ssr-sticky` second. On narrow screens the grid collapses to one column and the visual pins to the top while text scrolls beneath — no code change.
- **Pin offset / vertical centring:** raise `top` to clear a fixed header; use `top: calc(50vh - 220px)` to vertically centre a fixed-height card.
- **Pacing:** longer `min-height` on `.ssr-step` = slower, more deliberate reveals; shorter = snappier.
- **What reacts:** instead of a moving ring you could crossfade different images, scrub a `<video>`'s `currentTime`, swap background colours, or animate an SVG path — the `setActive(n)` hook stays the same.
- **Active sensitivity:** widen the band (`-35%`) to switch steps sooner, tighten it (`-48%`) to require near-perfect centring.

## Accessibility & performance

- **Content is real DOM, in order** — every caption is readable with JS off or IO unsupported (the demo falls back to revealing all steps). The visual is decorative furniture (`aria-hidden`) layered over a single `<img alt>`.
- **Reduced motion:** `prefers-reduced-motion` removes the fade/transform/ring transitions and the demo never attaches the rAF scroll loop — the progress bar just jumps per step. Steps are forced to `opacity:1` so nothing is left dimmed.
- **Cheap:** only `transform`/`opacity` are animated (compositor-friendly); IO does the visibility math off the main thread; the scroll handler is rAF-throttled and `{passive:true}`.

## Gotchas

- **`overflow:hidden`/`auto`/`scroll` on ANY ancestor of the sticky element silently breaks it.** That ancestor becomes the scroll container and, since it isn't actually scrollable, the element just scrolls away. Keep the `.ssr → .ssr-track → .ssr-sticky` chain `overflow:visible`. (It's fine on *descendants* — e.g. `.ssr-visual` clips the image with `overflow:hidden`, and that's harmless.) This is also why the framed "stage" here is rounded with `border-radius` only, not clipped.
- **Forgetting `align-items:start` / `align-self:start`** in a grid or flex parent stretches the sticky item to the full row height, leaving zero slack — it never appears to stick. Always pin a *natural-height* element.
- **The sticky element needs a taller sibling/parent.** If the steps column isn't meaningfully taller than the visual, there's nothing to scroll past and the pin is invisible.
- **`100%` rootMargin tuning is viewport-relative**: very tall steps plus a wide central band can leave brief moments with no active step at the very top/bottom — initialise to step 1 and ignore out-of-range indices.
- **A transformed ancestor** (`transform`, `filter`, `perspective`, `will-change`) creates a containing block that can re-anchor `sticky` unexpectedly — keep transforms on the sticky element or its children, not its scroll-spanning ancestors.
