← Scroll

Snap Sections

Full-screen sections that glide and snap into place. One flick of the wheel, a swipe or an arrow key glides smoothly to the next section, and its content rises in as it lands. Dots on the side show where you are. Past the first or last section the page scrolls on as normal.

Scroll or swipe inside ↓

01WelcomeScroll or swipe to glide on.
02GlideOne flick moves one section.
03SnapIt always lands square.
04The endKeep going to leave.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-snap-sections to the element holding your sections, each one the height of the screen.
  3. Leave it in the page to snap the page, or give it a height and overflow-y: auto to snap inside it.

Options

Add any of these attributes to the same element.

data-durationHow long each glide takes. Default 900 ms.
data-dotsShow a column of dots on the right; click one to go to that section.
data-autoplayMove on by itself every so many ms while in view, until the reader scrolls. Default 2800.
data-reveal="false"Don’t animate the content of each arriving section.

It only takes over the scroll while the sections fill the view, so a header or footer around them scrolls normally. With reduced motion it jumps instead of gliding.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Snap Sections: full-screen sections that glide and snap into place. One
    // flick of the wheel, a swipe or an arrow key glides smoothly to the next
    // section, and its content rises in as it arrives. Past the first or last
    // section, scrolling carries on as normal.
    // Usage: <div data-snap-sections>
    //          <section>…</section>   (each child is a section; make each
    //          <section>…</section>    one the height of the screen)
    //        </div>
    // If the element itself scrolls (overflow-y: auto, or hidden for no
    // scrollbar) the sections snap inside it; otherwise they snap the nearest
    // scrolling parent, or the page.
    // Optional: data-duration (the glide, in ms, default 900), data-dots (a
    // column of dots showing where you are; click one to go there),
    // data-autoplay (move on by itself every so many ms while in view, default
    // 2800, until the reader scrolls), data-reveal="false" (don't animate the
    // content of each arriving section).
    // Script use: animacious.snapSections(el, index) glides to a section.
    var DEFAULTS = { duration: 900, autoplay: 2800 };

    var css =
      '.sns-dots{position:sticky;top:0;height:0;z-index:3;pointer-events:none}' +
      '.sns-dots-in{position:absolute;right:14px;display:flex;flex-direction:column;gap:9px;transform:translateY(-50%);pointer-events:auto}' +
      '.sns-dot{display:block;width:9px;height:9px;padding:0;border:0;color:inherit;border-radius:99px;background:currentColor;opacity:.35;cursor:pointer;transition:height .35s cubic-bezier(.3,1.4,.5,1),opacity .35s}' +
      '.sns-dot.sns-on{height:24px;opacity:.95}' +
      '.sns-dot:focus-visible{outline:2px solid currentColor;outline-offset:3px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scrolls(node) {
      var o = getComputedStyle(node).overflowY;
      return o === 'auto' || o === 'scroll' || o === 'hidden';
    }

    function scroller(el) {
      if (scrolls(el)) return el;
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

    function setup(el) {
      var d = el.dataset;
      var duration = reduced() ? 0 : num(d.duration, DEFAULTS.duration);
      var reveal = d.reveal !== 'false' && !reduced();
      var sections = Array.prototype.slice.call(el.children);
      if (sections.length < 2) return;
      var box = scroller(el);
      var decorative = !!el.closest('[aria-hidden="true"]');   // e.g. a preview: no controls
      var index = 0, busy = false, quietUntil = 0, dots = [], dotsIn = null;
      var autoTimer = null, autoDir = 1, inView = false, userTook = false;

      function getY() { return box ? box.scrollTop : window.pageYOffset; }
      function setY(y) { if (box) box.scrollTop = y; else window.scrollTo(0, y); }
      function view() {
        if (!box) return { top: 0, h: window.innerHeight };
        var b = box.getBoundingClientRect();
        return { top: b.top + box.clientTop, h: box.clientHeight };
      }
      function topOf(i) { return sections[i].getBoundingClientRect().top - view().top + getY(); }

      // Only take over the scroll while the sections fill the view.
      function covering() {
        if (box === el) return true;
        var v = view(), r = el.getBoundingClientRect();
        return r.top <= v.top + 2 && r.bottom >= v.top + v.h - 2;
      }

      function nearest() {
        var y = getY(), best = 0;
        for (var i = 1; i < sections.length; i++) {
          if (Math.abs(topOf(i) - y) < Math.abs(topOf(best) - y)) best = i;
        }
        return best;
      }

      function mark(i) {
        index = i;
        sections.forEach(function (s, k) { s.classList.toggle('sns-active', k === i); });
        dots.forEach(function (dot, k) {
          dot.classList.toggle('sns-on', k === i);
          if (dot.tagName === 'BUTTON') dot.setAttribute('aria-current', k === i ? 'true' : 'false');
        });
      }

      function rise(i) {
        if (!reveal) return;
        var kids = Array.prototype.slice.call(sections[i].children);
        anime.utils.remove(kids);
        anime.animate(kids, { opacity: [0, 1], y: [28, 0], duration: 750, delay: anime.stagger(90, { start: 120 }), ease: 'outQuart' });
      }

      function goTo(i) {
        i = Math.max(0, Math.min(sections.length - 1, i));
        var from = getY(), to = topOf(i);
        var changed = i !== index;
        mark(i);
        if (Math.abs(to - from) < 1) return;
        if (changed) rise(i);
        if (!duration) { setY(to); return; }
        busy = true;
        var t0 = performance.now();
        (function step(now) {
          var t = Math.min(1, (now - t0) / duration);
          setY(from + (to - from) * ease(t));
          if (t < 1) requestAnimationFrame(step);
          else { busy = false; quietUntil = Math.max(quietUntil, Date.now() + 60); }
        })(t0);
      }

      // A wheel gesture (including a trackpad's long tail of events) moves one section.
      function onWheel(e) {
        if (Math.abs(e.deltaY) < 2 || !covering()) return;
        var now = Date.now(), ready = !busy && now >= quietUntil;
        var dir = e.deltaY > 0 ? 1 : -1;
        if (ready && (index + dir < 0 || index + dir >= sections.length)) return;   // let the page carry on
        e.preventDefault();
        quietUntil = Math.max(quietUntil, now + 200);
        stopAuto();
        if (ready) goTo(index + dir);
      }

      var touchY = null;
      function onTouchStart(e) { touchY = e.touches[0].clientY; stopAuto(); }
      function onTouchMove(e) {
        if (touchY === null || !covering()) return;
        var dy = touchY - e.touches[0].clientY;
        var dir = dy > 0 ? 1 : -1;
        if (busy || (index + dir >= 0 && index + dir < sections.length)) { if (e.cancelable) e.preventDefault(); }
      }
      function onTouchEnd(e) {
        if (touchY === null) return;
        var dy = touchY - e.changedTouches[0].clientY;
        touchY = null;
        if (busy || Math.abs(dy) < 36 || !covering()) return;
        var dir = dy > 0 ? 1 : -1;
        if (index + dir >= 0 && index + dir < sections.length) goTo(index + dir);
      }

      function onKey(e) {
        if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(e.target.tagName) || e.target.isContentEditable) return;
        if (!covering()) return;
        var dir = { ArrowDown: 1, PageDown: 1, ' ': 1, ArrowUp: -1, PageUp: -1 }[e.key];
        if (!dir || busy) { if (dir) e.preventDefault(); return; }
        if (index + dir < 0 || index + dir >= sections.length) return;
        e.preventDefault();
        stopAuto();
        goTo(index + dir);
      }

      // After any other scrolling (scrollbar, find-in-page), settle on the nearest section.
      var settleTimer = null;
      function onScroll() {
        if (busy) return;
        clearTimeout(settleTimer);
        settleTimer = setTimeout(function () {
          if (busy || touchY !== null || !covering()) return;
          var i = nearest();
          if (Math.abs(topOf(i) - getY()) > 1) goTo(i);
          else mark(i);
        }, 160);
      }

      function stopAuto() {
        userTook = true;
        clearInterval(autoTimer);
        autoTimer = null;
      }
      function startAuto() {
        if (d.autoplay === undefined || userTook || autoTimer || reduced()) return;
        autoTimer = setInterval(function () {
          if (busy) return;
          if (index + autoDir < 0 || index + autoDir >= sections.length) autoDir = -autoDir;
          goTo(index + autoDir);
        }, num(d.autoplay, DEFAULTS.autoplay));
      }

      if (d.dots !== undefined) {
        var bar = document.createElement('div');
        bar.className = 'sns-dots';
        dotsIn = document.createElement(decorative ? 'span' : 'nav');
        dotsIn.className = 'sns-dots-in';
        if (!decorative) dotsIn.setAttribute('aria-label', 'Sections');
        sections.forEach(function (s, i) {
          var dot = document.createElement(decorative ? 'span' : 'button');
          dot.className = 'sns-dot';
          if (!decorative) {
            dot.type = 'button';
            dot.setAttribute('aria-label', 'Section ' + (i + 1) + ' of ' + sections.length);
            dot.addEventListener('click', function () { stopAuto(); goTo(i); });
          }
          dotsIn.appendChild(dot);
          dots.push(dot);
        });
        bar.appendChild(dotsIn);
        el.insertBefore(bar, el.firstChild);
      }

      function measure() { if (dotsIn) dotsIn.style.top = view().h / 2 + 'px'; }

      measure();
      mark(nearest());

      if (!decorative) {
        var target = box || window;
        target.addEventListener('wheel', onWheel, { passive: false });
        target.addEventListener('touchstart', onTouchStart, { passive: true });
        target.addEventListener('touchmove', onTouchMove, { passive: false });
        target.addEventListener('touchend', onTouchEnd, { passive: true });
        (box || document).addEventListener('keydown', onKey);
        (box || window).addEventListener('scroll', onScroll, { passive: true });
        (box || window).addEventListener('pointerdown', function (e) { if (!e.target.closest || !e.target.closest('.sns-dot')) stopAuto(); });
      }
      window.addEventListener('resize', function () {
        measure();
        if (!busy && covering()) setY(topOf(index));
      });

      new IntersectionObserver(function (entries) {
        inView = entries[0].isIntersecting;
        if (inView) startAuto();
        else { clearInterval(autoTimer); autoTimer = null; }
      }, { threshold: 0.4 }).observe(box && box !== el ? box : el);

      el._snsGo = goTo;
    }

    function init() {
      document.querySelectorAll('[data-snap-sections]').forEach(function (el) {
        if (el._sns) return;
        el._sns = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.snapSections = function (el, i) { if (el && el._snsGo) el._snsGo(i); };

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<main data-snap-sections data-dots>
  <section style="height: 100vh">…</section>
  <section style="height: 100vh">…</section>
  <section style="height: 100vh">…</section>
</main>