---
name: scroll-snap
description: Use when you want "Clean, app-like, structured" - Locks scrolling into full sections or cards.
---

# Scroll Snap

> **Category:** Scroll  -  **Personality:** Clean, app-like, structured
>
> **Best use:** Product tours, mobile layouts
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 1/5

## What it is

Scroll-snap turns a normal scroll into a series of deliberate stops: instead of drifting to a random offset, the viewport "clicks" to the start of the next full panel or card. It is the native CSS mechanism behind app-style onboarding flows, product tours and full-screen story sections. Here it lives **inside a fixed-height container** (a faux app screen) so the rest of the page keeps scrolling normally — the snapping is sandboxed to the "Voyage" travel-app tour. A thin progress bar, a `01 / 05` step counter and a clickable dot rail are kept in sync with the scroll position via `IntersectionObserver`.

## Dependencies / CDN

None - pure CSS for the effect; vanilla JS only to mirror the scroll position in the HUD (dots / counter / progress). The demo loads display + UI + mono 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=JetBrains+Mono:wght@500;700&family=Manrope:wght@400;500;600;700;800&family=Sora:wght@500;600;700;800&display=swap" rel="stylesheet">
```

## HTML

A fixed-height **deck** (the scroll container) holds full-height **panels**; the HUD pieces are siblings of the deck so they stay put while it scrolls.

```html
<div class="ss-frame">
  <!-- the scroll container = the effect -->
  <div class="ss-deck" id="ss-deck" tabindex="0" role="group"
       aria-label="Product tour — 5 steps, scroll or use the dots">
    <section class="ss-panel in-view">
      <div class="ss-bg" style="background-image:url(/.../landscape-mountain.jpg)"></div>
      <div class="ss-scrim"></div>
      <div class="ss-card">
        <span class="ss-kicker">Voyage · Product tour</span>
        <h3 class="ss-title">Plan less. Wander more.</h3>
        <p class="ss-text">Your pocket travel companion…</p>
      </div>
    </section>
    <!-- …4 more <section class="ss-panel">… -->
  </div>

  <!-- HUD: siblings of the deck, fixed over the frame -->
  <div class="ss-progress"><div class="ss-bar" id="ss-bar"></div></div>
  <div class="ss-topbar">
    <span class="ss-brand"><span>V</span>Voyage</span>
    <span class="ss-counter"><b id="ss-cur">01</b><i>/ 05</i></span>
  </div>
  <nav class="ss-rail" aria-label="Tour steps">
    <button class="ss-dot is-active" type="button" aria-label="Go to step 1"></button>
    <!-- one .ss-dot per panel -->
  </nav>
</div>
```

## CSS

```css
/* 1) THE SCROLL CONTAINER — the only lines the effect truly needs */
.ss-deck{
  position:absolute; inset:0;            /* fills the fixed-height frame */
  overflow-y:auto;                       /* it must be its own scroller */
  scroll-snap-type: y mandatory;         /* snap on the Y axis, always */
  scroll-behavior: smooth;               /* eased programmatic jumps */
  scrollbar-width:none;                  /* hide the bar (Firefox) */
}
.ss-deck::-webkit-scrollbar{ display:none; }   /* hide the bar (WebKit) */

.ss-panel{
  height:100%;                           /* one panel == one viewport */
  scroll-snap-align: start;              /* snap its top edge to the top */
  scroll-snap-stop: always;              /* never skip a panel in one fling */
  position:relative; overflow:hidden;
  display:flex; align-items:flex-end;
}

/* 2) The frame is just a fixed-height, clipped box so the page still scrolls */
.ss-frame{ position:relative; height:564px; border-radius:24px; overflow:hidden; isolation:isolate; }

/* 3) Dressing: full-bleed image + scrim + glass card */
.ss-bg{ position:absolute; inset:0; background-size:cover; background-position:center;
  transform:scale(1.1); transition:transform 7s ease-out; }      /* slow ken-burns */
.ss-panel.in-view .ss-bg{ transform:scale(1); }
.ss-scrim{ position:absolute; inset:0;
  background:linear-gradient(180deg,rgba(7,8,12,.18),rgba(7,8,12,.04) 30%,rgba(7,8,12,.74) 80%,rgba(7,8,12,.93)); }
.ss-card{ position:relative; z-index:2; margin:0 30px 30px; max-width:484px; padding:24px 26px; border-radius:20px;
  background:linear-gradient(135deg,rgba(255,255,255,.13),rgba(255,255,255,.04));
  backdrop-filter:blur(16px) saturate(140%); border:1px solid rgba(255,255,255,.16); color:#fff;
  opacity:0; transform:translateY(18px); transition:transform .72s cubic-bezier(.16,.84,.34,1) .08s, opacity .6s ease; }
.ss-panel.in-view .ss-card{ opacity:1; transform:translateY(0); }   /* content reveals on arrival */

/* 4) HUD that stays fixed while the deck scrolls */
.ss-progress{ position:absolute; top:0; left:0; right:0; height:3px; background:rgba(255,255,255,.16); }
.ss-bar{ height:100%; width:0; background:linear-gradient(90deg,#ff9a62,#ffce6b); transition:width .15s linear; }
.ss-rail{ position:absolute; right:15px; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:11px; }
.ss-dot{ width:9px; height:9px; border:0; border-radius:999px; background:rgba(255,255,255,.34); cursor:pointer;
  transition:height .35s cubic-bezier(.16,.84,.34,1), background .3s; }
.ss-dot.is-active{ height:26px; background:linear-gradient(180deg,#ff9a62,#ffce6b); box-shadow:0 0 12px #ff9a62; }

/* 5) Honour reduced-motion: no smooth scroll, no ken-burns, content always visible */
@media (prefers-reduced-motion: reduce){
  .ss-deck{ scroll-behavior:auto; }
  .ss-bg{ transform:none!important; transition:none; }
  .ss-card{ opacity:1!important; transform:none!important; transition:none; }
}

/* 6) On phones, move the dot rail to a horizontal strip at the bottom */
@media (max-width:640px){
  .ss-rail{ flex-direction:row; right:auto; left:50%; top:auto; bottom:16px; transform:translateX(-50%); }
  .ss-dot.is-active{ height:9px; width:26px; }
}
```

## JavaScript

Optional — snapping is 100% CSS. JS only **reflects** the scroll position in the HUD (active dot, `01/05` counter, progress bar) and lets the dots jump to a step. `IntersectionObserver` is rooted on the deck so it reports which panel is centered.

```js
(function(){
  var deck=document.getElementById('ss-deck'); if(!deck) return;
  var bar=document.getElementById('ss-bar'), cur=document.getElementById('ss-cur');
  var panels=[].slice.call(deck.querySelectorAll('.ss-panel'));
  var dots=[].slice.call(document.querySelectorAll('.ss-dot'));
  var reduce=matchMedia('(prefers-reduced-motion: reduce)').matches, active=0;

  function setActive(i){
    active=i;
    dots.forEach(function(d,k){ d.classList.toggle('is-active',k===i);
      if(k===i){d.setAttribute('aria-current','step');}else{d.removeAttribute('aria-current');} });
    panels.forEach(function(p,k){ p.classList.toggle('in-view',k===i); });
    if(cur) cur.textContent=(i<9?'0':'')+(i+1);
  }

  // progress bar (rAF-throttled)
  var tick=false;
  deck.addEventListener('scroll',function(){
    if(tick) return; tick=true;
    requestAnimationFrame(function(){
      var max=deck.scrollHeight-deck.clientHeight;
      bar.style.width=(max>0?deck.scrollTop/max*100:0).toFixed(2)+'%'; tick=false;
    });
  },{passive:true});

  // which panel is centered → drive the HUD (observer scoped to the deck)
  var io=new IntersectionObserver(function(entries){
    var best=-1,r=0;
    entries.forEach(function(e){ if(e.isIntersecting&&e.intersectionRatio>r){r=e.intersectionRatio;best=panels.indexOf(e.target);} });
    if(best>-1&&r>=0.55) setActive(best);
  },{root:deck,threshold:[0.55,0.8,1]});
  panels.forEach(function(p){ io.observe(p); });

  // dots jump to a step
  dots.forEach(function(d,i){ d.addEventListener('click',function(){
    deck.scrollTo({top:i*deck.clientHeight, behavior:reduce?'auto':'smooth'}); setActive(i);
  }); });

  setActive(0);
})();
```

## How it works

- **`scroll-snap-type: y mandatory`** on the scroll container tells the browser: after any scroll on the Y axis, settle on a snap point (`mandatory` = always; `proximity` = only if close).
- **`scroll-snap-align: start`** on each panel marks its top edge as the snap point. With panels at `height:100%`, every snap lands on exactly one full screen.
- **`scroll-snap-stop: always`** prevents a fast flick from flying past panels — the user advances one step at a time, which is what makes it feel "app-like".
- The container is **`position:absolute; inset:0` inside a fixed-height frame**, so it owns its own scrollbar; the page behind it scrolls independently. Nothing hijacks the window.
- JS is purely cosmetic: an `IntersectionObserver` (rooted on the deck) names the centered panel to light its dot and bump the counter; a scroll listener stretches the progress bar.

## Customization

- **Axis:** switch to `scroll-snap-type: x mandatory` + lay panels out in a row (`display:flex`) for a horizontal carousel; snap-align stays `start`.
- **Firmness:** `mandatory` forces a snap on every release; `proximity` only snaps when you're already near a point (gentler, better for long-form reading).
- **Padding the snap:** `scroll-padding-top` on the container offsets where panels land — useful if a sticky header overlaps the top.
- **Card snapping:** set panels to `height:auto` + `scroll-snap-align:center` and shrink them to snap a row of cards instead of full screens.
- **Pace the reveal:** the `.in-view` entrance (translate/opacity) and the ken-burns `scale` are independent of the snap — retime or remove them freely.

## Accessibility & performance

- Snap is GPU-cheap (no JS scroll-jacking, no rAF scroll loop driving layout) — the only listener here is a throttled progress update. Performance cost is genuinely low.
- The deck is **focusable** (`tabindex="0"`, `role="group"`, `aria-label`) so keyboard users can scroll it with arrows / PageDown / Space; the dots are real `<button>`s with `aria-label` + `aria-current`.
- **`prefers-reduced-motion`** is respected: smooth scroll falls back to instant, the ken-burns and card-reveal transitions are disabled, and card content stays visible.
- Keep panels at `height:100%` of a sized container, not `100vh` inside the deck, so it never fights the mobile URL-bar resize.

## Gotchas

- **`scroll-snap-type` goes on the scroll container, `scroll-snap-align` on the children.** Swapping them silently does nothing.
- The snap container must actually scroll — it needs a constrained size (`height` + `overflow:auto`). On `height:auto` content there is nothing to snap.
- **`mandatory` can trap users** if a panel is taller than the viewport (they can't stop mid-panel to read). Use `proximity` for variable-height content.
- Hiding the scrollbar (`scrollbar-width:none` + `::-webkit-scrollbar`) is cosmetic only — keep the keyboard/dot affordances so the content is still reachable.
- `IntersectionObserver` must be rooted on the **deck** (`{root:deck}`), not the document, or ratios are computed against the wrong viewport and the dots desync.
- Avoid animating `scroll-snap` itself; animate `transform`/`opacity` on the panel contents instead (as the `.in-view` reveal does).
