Copied!
Full page

Snapping horizontal sections

A pinned stage that slides a row of sections sideways as you scroll down, easing to a stop on each one.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

HeroElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Snapping horizontal sections" for my site using the Enrichr Library element at https://enrichr.dev/elements/snap-sections-horizontal.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/snap-sections-horizontal.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- titles: Plan:Map the work before touching pixels, Build:Ship the smallest real version first, Test:Watch real people use it, Launch:Release it and keep listening
- snap: 70
- pin: 60
- manual: 0
- accent: #e6ff55
- background: #0c1e18

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/snap-sections-horizontal.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-snap">
  <div class="ef-snap__stage">
    <div class="ef-snap__row"></div>
    <div class="ef-snap__ui">
      <div class="ef-snap__track" aria-hidden="true"><span class="ef-snap__fill"></span></div>
      <div class="ef-snap__dots" role="group" aria-label="Sections"></div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-snap { position: relative; width: 100%; height: calc(var(--ef-sh, 24rem) + var(--ef-run, 0px)); font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; color: #fbf8f1; }
.ef-snap__stage { position: sticky; top: 0; height: var(--ef-sh, 24rem); overflow: hidden; background: var(--ef-snap-bg, #0c1e18); }
.ef-snap__row { display: flex; height: 100%; width: calc(var(--ef-n, 4) * 100%); will-change: transform; }
.ef-snap__sec { position: relative; flex: 0 0 calc(100% / var(--ef-n, 4)); height: 100%; box-sizing: border-box; padding: 1.4rem 1.6rem 3.4rem; display: flex; flex-direction: column; justify-content: flex-end; overflow: hidden; background: radial-gradient(circle at 80% 24%, color-mix(in srgb, var(--ef-snap-acc, #e6ff55) 24%, transparent) 0 22%, transparent 22.5%), linear-gradient(160deg, color-mix(in srgb, var(--ef-snap-bg, #0c1e18) calc(100% - var(--i, 0) * 11%), #005236), var(--ef-snap-bg, #0c1e18)); }
.ef-snap__num { position: absolute; top: 0.4rem; left: 1.2rem; font-size: clamp(5rem, 28cqh, 12rem); font-weight: 900; line-height: 1; letter-spacing: -0.05em; color: transparent; -webkit-text-stroke: 2px color-mix(in srgb, var(--ef-snap-acc, #e6ff55) 55%, transparent); }
.ef-snap__t { position: relative; margin: 0; font-size: clamp(1.8rem, 7vw, 4rem); font-weight: 900; line-height: 0.95; letter-spacing: -0.03em; text-transform: uppercase; }
.ef-snap__s { position: relative; margin: 0.6rem 0 0; max-width: 24rem; font-size: 0.95rem; line-height: 1.4; opacity: 0.8; }
.ef-snap__ui { position: absolute; left: 1.6rem; right: 1.6rem; bottom: 1rem; display: flex; align-items: center; gap: 1rem; z-index: 2; }
.ef-snap__track { flex: 1; height: 3px; border-radius: 2px; background: rgba(251, 248, 241, 0.2); overflow: hidden; }
.ef-snap__fill { display: block; height: 100%; width: 100%; background: var(--ef-snap-acc, #e6ff55); transform-origin: 0 50%; transform: scaleX(0); }
.ef-snap__dots { display: flex; gap: 0.35rem; }
.ef-snap__dot { width: 1.4rem; height: 1.4rem; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; position: relative; }
.ef-snap__dot::after { content: ""; position: absolute; left: 50%; top: 50%; width: 0.55rem; height: 0.55rem; margin: -0.275rem 0 0 -0.275rem; border-radius: 50%; background: rgba(251, 248, 241, 0.4); transition: background 0.2s, transform 0.2s; }
.ef-snap__dot[aria-current="true"]::after { background: var(--ef-snap-acc, #e6ff55); transform: scale(1.35); }
.ef-snap__dot:focus-visible { outline: 2px solid var(--ef-snap-acc, #e6ff55); outline-offset: 1px; }
.ef-snap--calm { height: auto; }
.ef-snap--calm .ef-snap__stage { position: relative; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; }
.ef-snap--calm .ef-snap__stage:focus-visible { outline: 2px solid var(--ef-snap-acc, #e6ff55); outline-offset: -4px; }
.ef-snap--calm .ef-snap__row { transform: none !important; }
.ef-snap--calm .ef-snap__sec { scroll-snap-align: start; flex-basis: calc(100% / var(--ef-n, 4)); padding-bottom: 1.6rem; }
.ef-snap--calm .ef-snap__ui { display: none; }
.ef-snap__stage { container-type: size; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Snap sections horizontal: a stage pins to the top of the screen while vertical scrolling slides a row of sections sideways.
// Scroll progress is mapped through a snapping curve, so each section dwells on screen and then glides to the next, and a
// progress bar plus dot buttons show and control where you are. The dots scroll the page to that section.
// It reads the real page scroll (sticky position, no library). With reduced motion it becomes a plain swipeable, snap-scrolling row.
// mount(root, { titles ("Title:Subtitle" items, comma separated), snap (0-100 snap strength), pin (scroll length per section, % of screen height),
//               manual (0 = follow scroll, 1-100 = fixed progress %), accent, background })
function mount(root, o) {
  // Sticky pinning needs every ancestor to leave overflow visible; inside a clipped preview panel, fall back to the swipeable row.
  function clipped(el) { for (el = el.parentElement; el && el !== document.documentElement; el = el.parentElement) { if (getComputedStyle(el).overflow !== "visible") return true; } return false; }
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches || clipped(root);
  var box = root.querySelector(".ef-snap");
  var stage = root.querySelector(".ef-snap__stage");
  var row = root.querySelector(".ef-snap__row");
  var fill = root.querySelector(".ef-snap__fill");
  var dotsBox = root.querySelector(".ef-snap__dots");
  var opts = {}, items = [], dots = [], raf = 0, visible = true, io = null;
  var pos = 0, lastDrawn = -1, current = -1;

  function parse(str) {
    return String(str).split(",").map(function (c) {
      var p = c.split(":");
      return { t: (p[0] || "").trim(), s: p.slice(1).join(":").trim() };
    }).filter(function (i) { return i.t; }).slice(0, 8);
  }
  function setRun() {
    var vh = window.innerHeight || 800;
    var run = (reduce || opts.manual > 0) ? 0 : Math.max(0, (items.length - 1) * opts.pin / 100 * vh);
    box.style.setProperty("--ef-run", run.toFixed(0) + "px");
  }
  function render() {
    if (items.length < 2) items = parse("One:First, Two:Second");
    row.textContent = ""; dotsBox.textContent = ""; dots = [];
    box.style.setProperty("--ef-n", items.length);
    box.style.setProperty("--ef-sh", opts.height + "px");
    box.style.setProperty("--ef-snap-acc", opts.accent);
    box.style.setProperty("--ef-snap-bg", opts.background);
    items.forEach(function (it, i) {
      var s = document.createElement("section");
      s.className = "ef-snap__sec"; s.style.setProperty("--i", i);
      s.setAttribute("aria-label", "Section " + (i + 1) + " of " + items.length + ": " + it.t);
      var n = document.createElement("span"); n.className = "ef-snap__num"; n.setAttribute("aria-hidden", "true"); n.textContent = String(i + 1).padStart(2, "0");
      var h = document.createElement("h3"); h.className = "ef-snap__t"; h.textContent = it.t;
      s.appendChild(n); s.appendChild(h);
      if (it.s) { var p = document.createElement("p"); p.className = "ef-snap__s"; p.textContent = it.s; s.appendChild(p); }
      row.appendChild(s);
      var d = document.createElement("button");
      d.type = "button"; d.className = "ef-snap__dot"; d.setAttribute("aria-label", "Go to section " + (i + 1) + ": " + it.t);
      d.addEventListener("click", function () { go(i); });
      dotsBox.appendChild(d); dots.push(d);
    });
    box.classList.toggle("ef-snap--calm", reduce);
    if (reduce) { stage.tabIndex = 0; stage.setAttribute("role", "region"); stage.setAttribute("aria-label", "Sections, scroll sideways"); }
    setRun();
    lastDrawn = -1; current = -1;
    apply(pos);
  }
  function progress() {
    if (opts.manual > 0) return opts.manual / 100;
    var r = box.getBoundingClientRect(), run = r.height - stage.getBoundingClientRect().height;
    return run > 1 ? Math.min(1, Math.max(0, -r.top / run)) : 0;
  }
  function snapMap(p) {
    var n = items.length, t = p * (n - 1), i = Math.min(n - 2, Math.floor(t)), f = t - i;
    var q = Math.min(1, Math.max(0, (f - 0.3) / 0.4)), e = q * q * q * (q * (q * 6 - 15) + 10);
    return i + f + (e - f) * (opts.snap / 100);
  }
  function apply(v) {
    if (Math.abs(v - lastDrawn) < 0.0004) return;
    lastDrawn = v;
    var n = items.length;
    row.style.transform = "translate3d(" + (-v * 100 / n).toFixed(3) + "%,0,0)";
    fill.style.transform = "scaleX(" + Math.min(1, v / (n - 1)).toFixed(4) + ")";
    var c = Math.round(v);
    if (c !== current) {
      current = c;
      dots.forEach(function (d, i) { if (i === c) d.setAttribute("aria-current", "true"); else d.removeAttribute("aria-current"); });
    }
  }
  function go(i) {
    var r = box.getBoundingClientRect(), run = r.height - stage.getBoundingClientRect().height;
    if (run <= 1) return;
    window.scrollTo({ top: window.scrollY + r.top + (i / (items.length - 1)) * run, behavior: reduce ? "auto" : "smooth" });
  }
  function tick() {
    raf = requestAnimationFrame(tick);
    if (!visible) return;
    var target = snapMap(progress());
    pos += (target - pos) * 0.14;
    if (Math.abs(target - pos) < 0.001) pos = target;
    apply(pos);
  }
  function onResize() { setRun(); }
  function update(next) {
    opts = Object.assign({ titles: "Plan:Map the work before touching pixels, Build:Ship the smallest real version first, Test:Watch real people use it, Launch:Release it and keep listening", snap: 70, pin: 60, height: 384, manual: 0, accent: "#e6ff55", background: "#0c1e18" }, opts, next || {});
    ["snap", "pin", "height", "manual"].forEach(function (k) { opts[k] = Number(opts[k]); });
    items = parse(opts.titles);
    pos = 0;
    render();
    if (!reduce) { pos = snapMap(progress()); apply(pos); }
  }
  update(o);
  if (!reduce) {
    window.addEventListener("resize", onResize);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
    raf = requestAnimationFrame(tick);
  }
  return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); window.removeEventListener("resize", onResize); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"titles":"Plan:Map the work before touching pixels, Build:Ship the smallest real version first, Test:Watch real people use it, Launch:Release it and keep listening","snap":70,"pin":60,"manual":0,"accent":"#e6ff55","background":"#0c1e18"});

How it works

The element is made tall: the stage height plus extra scroll length. The stage inside is position: sticky at the top, so it stays put while the page scrolls past. Progress is how far the tall block has moved, from 0 to 1. A snapping curve turns it into a section position: inside each section's range the position stays near a whole number for the first and last 30 percent and glides through the middle 40 percent with a smootherstep curve, and the snap strength blends between that and a plain linear slide. The row is moved with a translate3d eased toward that position each frame. The dots scroll the page to the matching spot.

Accessibility and performance

Each section is a labelled region and the dots are real buttons with labels and a current marker, so the sections can be reached without scrolling by feel. With prefers-reduced-motion the pin is dropped and the sections become an ordinary horizontal row that snaps as you swipe or scroll sideways, and it can be focused and moved with the arrow keys.

One bounding-rect read and two transform writes per frame while on screen, paused when off screen. Nothing triggers layout during scrolling. The extra scroll length is set once and again on resize.

SectionFull page
TechniquesScroll-driven
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byHorizontal snapping sections (simple) - ScrollTrigger by GSAP on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Scroll spring headingAn oversized heading slides sideways with scroll progress through a spring, lagging and settling.

FAQ

Why not use real scroll-snap?

Native scroll-snap fights with the pin, because the page itself would have to snap. Here the page scrolls freely and only the row snaps, so wheel, touch and keyboard scrolling all feel the same.

Does it work with a smooth-scroll library?

Yes. It reads the element's position every frame, so any method that moves the page drives it.

Will sticky work inside my layout?

Sticky needs the element's ancestors to avoid overflow hidden, auto or scroll. If a parent clips overflow, the stage will scroll away normally but the row still moves with progress.