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.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Horizontal 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.
More elements
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.