Scroll✨ Premium

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.

CSS Scroll SnapJavaScriptNavigation

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

Funding pitch projected in a meeting — jumping to the Finance slide during Q&A — Scroll Snap Sections example 1

① Funding pitch projected in a meeting — jumping to the Finance slide during Q&A

WhenA founder presents five vertical slides (Problem, Solution, Market, Team, Finance) on a shared screen; during Q&A an investor asks for the numbers and the Finance slide must come up without passing through the other three.
WhyThe container only rests at 0, 280, 560, 840 or 1,120 px: a slide is always whole on screen, never cut. The dot column, absolutely positioned on the right, says where you are at all times; with the six click lines added, a dot becomes the direct access a Q&A session demands — Vertical Snap (fx-0593), pure CSS, shows neither an indicator nor direct access.
SettingsLeft-aligned sections (align-items: flex-start, padding: 28px 150px 28px 28px) to free the right column, where each dot gets a label (:has(.active) to highlight the current one); dots raised to 8 px, as <button>s, with the click listener; a “Slide n / 5” counter fed by the same Math.round; sold gradients kept.
Four-step SaaS onboarding form — going back to fix the Team step — Scroll Snap Sections example 2

② Four-step SaaS onboarding form — going back to fix the Team step

WhenA B2B tool chains Account, Team, Billing and Launch in a 280 px panel; from Billing, the user wants to edit the invitations of the Team step without losing what they typed.
WhyEach step fills exactly the container height and mandatory snapping forbids the in-between: you are on a step or on the next, never between. Going back is a click on the target step's dot, in 280 ms; the steps' content is not reloaded, it scrolls — fields keep their values. Snap + Progress (fx-0597) adds a progress bar this four-step flow has no need to show.
SettingsThe .snap-dots column taken out of absolute flow (position: static, transform: none, gap: 16px) to live in a sidebar with labels and numbers; four sections instead of five (gradients 1 to 4 of the sold code); an instance with its own ids, the index computed from clientHeight rather than 280; scrollbar hidden (scrollbar-width: none), the dots replace it.
Public tender application portal — checking a completed section — Scroll Snap Sections example 3

③ Public tender application portal — checking a completed section

WhenThe response to a public contract is split into five equal-height sections (Identity, References, Technical offer, Price, Attachments); the bidder, now on Price, wants to re-read the References before submitting.
WhyEqual-height sections and strict snapping suit a structured administrative form: each section shows whole, with its status (completed, in progress, to do), and the right-hand contents list mirrors the position unambiguously. A click on an earlier section's dot brings it back in under half a second (464 ms measured for three sections), without leaving the page or losing entries — Mixed Content Snap (fx-0596) would give sections of varying sizes, incompatible with this regularity.
SettingsThe five sold gradients replaced by five sober blues (.snap-section:nth-child(n)), dots in a “Contents” rail with a label and a state under each dot, each section as a form block (number, title, status pill, three rows); an instance with its own ids, click listener, pulsing hint removed.

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-scroll100% × 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-dotsposition: 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 nothingscrollTop 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-behavior stays smooth (11 px at 40 ms, 280 at rest), pulseHint keeps 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, no role, no tabindex, no click listener. If you add navigation (see Customize), make them <button type="button"> with an aria-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) through padding or 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-desc is 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 with aria-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 an aria-label on #snap-scroll for browsers that don't focus scrollable areas (Safari). Replace div.snap-title with a heading (h3) to give the five sections a structure, put aria-hidden="true" on the SVGs and on the dot column as long as it is not clickable. Integration: the parent of .snap-dots must stay position: relative (that is .demo-preview in 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.

Chrome 69+✓ Full
Firefox 68+✓ Full (native scrollbar, ::-webkit-scrollbar ignored)
Safari 15.4+✓ Full — 11 to 15.3: snapping without smooth scrolling
Edge 79+✓ Full
Mobile iOS 15.4+✓ Full (swipe)
Android Chrome✓ Full (measured swipe: 280 px, dot 2)

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):

index.html — structure
<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>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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

Not with the sold code: measured, a click at the center of the 4th dot leaves 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-indexscrollTo), 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.
Because the script divides 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.
Not by pasting the code twice: the script declares 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.