Scroll Snap Sections
A 280 px container with scroll-snap-type: y mandatory and scroll-behavior: smooth, five 280 px sections, and 342 bytes of JavaScript that light up dot Math.round(scrollTop / 280) on every scroll. The five 6 px dots are indicators: the sold code contains no click listener — direct access to a section is a six-line addition.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
Anatomy: the .demo-preview wrapper (position: relative, min-height: 300px, centered flex, #0a0a0f background, font-family: sans-serif) holds .snap-container#snap-scroll — 100% × 280px, overflow-y: auto, scroll-snap-type: y mandatory, scroll-behavior: smooth, a 4 px scrollbar styled through ::-webkit-scrollbar — and five 280px .snap-sections with scroll-snap-align: start (centered flex column, gap: 10px, padding: 24px), i.e. 1,400 px of content for 1,120 px of travel. Each section carries a 48 px .snap-icon (24 px SVG, 14 px radius), a .snap-title (1rem, 700) and a .snap-desc (.75rem, white at 40%, max-width: 220px); backgrounds are five 135° gradients assigned by :nth-child(1) to (5) (#0f0f23 → #1a1040, #0f1a23 → #102a3e, #1a0f1a → #3e102a, #0f230f → #103e10, #23200f → #3e3510), the icon picking up its section's hue (#818cf8, #22d3ee, #ec4899, #34d399, #fbbf24 on a 20% tint). On the right, .snap-dots#snap-dots — position: absolute, right: 12px, top: 50%, column, gap: 6px, z-index: 10 — stacks five 6px .st-snap-dots in white at 15% (transition: .3s); the .active class turns them #a855f7, glow 0 0 8px rgba(168, 85, 247, .5), scale(1.3). At the bottom, .scroll-hint-label “Scrollez pour explorer” (.65rem, uppercase, bottom: 8px) pulses between 30% and 70% opacity every 2 s (@keyframes pulseHint, infinite).
The snapping is entirely CSS; the script only observes. Measured in Chromium: a 60 or 120 px wheel tick starts the movement (21 then 52 px at 120 ms) and comes back to 0, a 280 px tick moves to the next section in 280 ms; at 1,120 px, a 500 px tick does nothing more. With mandatory, the only rest positions are 0, 280, 560, 840 and 1,120 px: a scrollTop forced to 140 is pulled back to 0, at 141 it is pushed to 280. The JS is one useful line: snapScroll.addEventListener('scroll', …) computes s = Math.round(snapScroll.scrollTop / 280) and runs classList.toggle('active', o === s) on each dot — so the dot switches mid-way, when the smooth scroll crosses 140 px, not on arrival. A programmatic scrollTo takes 280 ms for one section, 464 ms for three (840 px) and 540 ms for the full travel (1,120 px). The listener is unique and attached to the container: there is nothing to tear down, it goes away with the element. The keyboard works without any code: Tab focuses the container (Chromium and Firefox both make scrollable areas focusable), ↓ goes to 280 px, PageDown to 560, End to 1,120, and the dots follow; a touch swipe measured in mobile emulation lands at 280 px with dot 2 lit.
What the code doesn't do, measured: a click at the center of the 4th dot moves nothing — scrollTop 0 → 0, active dot unchanged. The dots are 6 × 6 px divs, cursor: auto, without role or tabindex, and the word click does not appear in the script: they are witnesses, as the catalog card says (“side pagination indicator”). The 280 in the JS is not read from the DOM: with sections changed to 360 px, section 3 lights dot 4, section 4 lights dot 5, section 5 lights none. The sold CSS contains 18 foreign rules out of 43 (1,489 bytes out of 4,641, 32%): .sn-progress-dot.active (the dot of Snap + Progress, fx-0597), .sticky-text-block.active, .split-screen-content.active, .snap-nav-dot.active — which reads var(--primary), defined only in the .demo-preview prelude —, then 14 rules from the site's customizer, favorites and code panel (.customizer-panel.active, .favorite-btn.active, .code-tab.active…). None of them matches an element on your page: delete them, don't fix them. The script is not isolated (a global-level const snapScroll): pasted twice in a page, the second copy dies on SyntaxError: Identifier 'snapScroll' has already been declared. Finally, the sold titles lack their French accents (“Rapidite”, “Modularite”, “Elegance”, “Fiabilite”) while “sécurité” has them, and the “Scrollez pour explorer” hint is never hidden: at 1,120 px it still pulses (display: block, measured opacity .40) and overlaps the bottom of the container by 10 px.
Accessibility
- prefers-reduced-motion is not handled. Measured under emulation: the media query matches, but
scroll-behaviorstayssmooth(11 px at 40 ms, 280 at rest),pulseHintkeeps running (animation-play-state: running) and the dot transition stays at .3 s. Add@media (prefers-reduced-motion: reduce) { .snap-container { scroll-behavior: auto } .scroll-hint-label { animation: none } .st-snap-dot { transition: none } }— the snapping itself is not an animation, it can stay. - The dots are not controls: 6 px
divs,cursor: auto, norole, notabindex, no click listener. If you add navigation (see Customize), make them<button type="button">with anaria-label(“Section 3 of 5”),aria-current="true"on the active one and a hit area of at least 24 px (WCAG 2.5.8) throughpaddingor a pseudo-element — 6 px, even ×1.3, is still an 8 px target. Measured dot contrast (UI component, 3:1 threshold): inactive 1.53:1 on section 1, active 4.77:1 on section 1 but 3.08:1 on section 5 (amber background); raise the inactive dot to 35% white. - Text:
.snap-descis white at 40% in 12 px — measured 3.81:1 on section 1, 3.58:1 on section 2, 3.34:1 on section 5, below the 4.5:1 threshold (WCAG 1.4.3); at 60% white, every section exceeds 6:1. The white titles measure 18.9:1. The pulsing hint is unreadable by construction (1.22:1 to 1.83:1 depending on the phase): keep it decorative witharia-hidden="true", or make it readable and hide it on the last section. - Keyboard and screen readers: the container takes focus on Tab (measured in Chromium and Firefox), the arrows, PageDown and End scroll it and the dots follow; still set
tabindex="0"and anaria-labelon#snap-scrollfor browsers that don't focus scrollable areas (Safari). Replacediv.snap-titlewith a heading (h3) to give the five sections a structure, putaria-hidden="true"on the SVGs and on the dot column as long as it is not clickable. Integration: the parent of.snap-dotsmust stayposition: relative(that is.demo-previewin the sold code), otherwise the column anchors to the viewport.
Browser compatibility
Requires ES2015 (const, arrow functions), scroll-snap-type / scroll-snap-align (2018 specification), scroll-behavior: smooth and box-shadow; ::-webkit-scrollbar is cosmetic only. Nothing here is scroll-driven in native CSS (animation-timeline): it is classic CSS snapping plus a JS listener, so the effect runs fully in Firefox — measured in Firefox 153: y mandatory applied, 200 px wheel → 280 px, dot 2 lit, zero errors. Zero dependencies.
Without JavaScript, snapping and smooth scrolling work (it is all CSS) but the active dot stays frozen on the first one, the active class being written in the HTML. Without scroll-snap (pre-2018 browsers), the container scrolls freely and the dots still follow, Math.round picking the nearest section.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="snap-container" id="snap-scroll">
<div class="snap-section">
<div class="snap-icon"><svg width="24" height="24" viewBox="0 0 24 24">…</svg></div>
<div class="snap-title">Rapidite</div>
<div class="snap-desc">…</div>
</div>
<div class="snap-section">…</div>
<div class="snap-section">…</div>
<div class="snap-section">…</div>
<div class="snap-section">…</div>
</div>
<div class="snap-dots" id="snap-dots">
<div class="st-snap-dot active"></div>
<div class="st-snap-dot"></div>
<div class="st-snap-dot"></div>
<div class="st-snap-dot"></div>
<div class="st-snap-dot"></div>
</div>
<span class="scroll-hint-label">Scrollez pour explorer</span>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Section height (CSS — .snap-container and .snap-section height: 280px; JS — / 280) |
280 px, three occurrences | All three values must move together: measured with 360 px in the CSS and 280 in the JS, section 3 lights dot 4 and section 5 lights none. Replace the script's 280 with snapScroll.clientHeight and the height becomes free — 100vh for full-screen sections. |
Number of sections (HTML — .snap-section × 5, .st-snap-dot × 5) |
5 | One section means one dot; the script counts the dots, not the sections. Gradients and icon tints stop at :nth-child(5): a sixth section has no background (the wrapper's #0a0a0f shows through), add its two rules. |
Snap type (CSS — scroll-snap-type: y mandatory) |
y mandatory | No rest position between two sections: a 120 px wheel tick returns to the start (measured). y proximity allows stopping along the way, and Math.round then picks the nearest section. |
Smooth scrolling (CSS — scroll-behavior: smooth) |
smooth | 280 ms per section, 540 ms for the full travel, the dot switching half-way (140 px). Remove it for an instant change; it is also the property to neutralize under prefers-reduced-motion. |
Dots (CSS — .st-snap-dot, .st-snap-dot.active) |
6 px, white 15% → #a855f7, 8 px glow, scale(1.3), .3 s transition | Size, resting and active colors. 8 to 10 px reads from a distance; the inactive dot at 35% white reaches 3:1 on the dark backgrounds; the active color can pick up the current section's icon hue. |
| Click on the dots (JS — to add) | absent | Six lines after the existing listener: snapDots.forEach((d, i) => d.addEventListener('click', () => snapScroll.scrollTo({ top: i * 280, behavior: 'smooth' }))), with cursor: pointer on .st-snap-dot. The dot lights up by itself mid-way, through the scroll listener. |
“Scrollez pour explorer” hint (HTML — .scroll-hint-label; CSS — pulseHint 2 s) |
always visible, 30 → 70% pulse | Free text. To hide it on the last section, add hint.hidden = s === snapDots.length - 1 inside the listener; to hide it on the first gesture, test snapScroll.scrollTop > 0. |
Backgrounds and icons (CSS — .snap-section:nth-child(n), .snap-section:nth-child(n) .snap-icon) |
5 gradients at 135° + 5 icon hues | Ten rules coupled by position. A single background for all sections (.snap-section { background: … }) gives an application panel; the sold gradients suit a deck or a presentation page. |
FAQ
scrollTop at 0 and the first dot lit — the script (342 bytes) only listens to the container's scroll. Navigation is a six-line addition (see Customize) and the dots should then become <button>s. Snap + Progress (fx-0597) ships that click out of the box (data-index → scrollTo), computes the index from clientHeight and adds a progress bar; Vertical Snap (fx-0593) is pure CSS, with no indicator. Pick this one for its overlaid dot column, centered on the right edge, and its minimal script — accepting to write the click yourself.scrollTop by the constant 280, without reading the DOM. Measured with 360 px sections: 720 / 280 = 2.57, rounded to 3, so section 3 lights dot 4; section 5 (1,440 px) gives 5, an index that doesn't exist, and nothing is lit any more. Replace the constant with snapScroll.clientHeight (the container's visible height, equal to one section's) and the same rule holds for 280 px, 360 px or 100vh; that is what fx-0597 does.const snapScroll at the global level and targets #snap-scroll and #snap-dots, two ids. Measured: the second copy stops on SyntaxError: Identifier 'snapScroll' has already been declared and never runs. Wrap the script in an immediately invoked function and replace the ids with an attribute: document.querySelectorAll('[data-snap]').forEach(c => { const dots = c.parentElement.querySelectorAll('.st-snap-dot'); c.addEventListener('scroll', () => { const s = Math.round(c.scrollTop / c.clientHeight); dots.forEach((d, i) => d.classList.toggle('active', i === s)); }); }) — each block then has its own dots and its own height.