Scroll✨ Premium

Smooth Snap

Four 280 px sections under scroll-snap-type: y mandatory and scroll-snap-stop: always; an IntersectionObserver (threshold 0.6, i.e. 168 px visible) adds the in-view class to the active section and a 700 ms cascade reveals icon, title and text. Nothing handles prefers-reduced-motion, and the stop made no measurable difference on the bench.

JSSnapSmooth

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

Feature-by-feature demo — product page of an accounting app — Smooth Snap example 1

① Feature-by-feature demo — product page of an accounting app

WhenProduct page of a bookkeeping tool: four features (Invoicing, VAT return, Cash flow, Exports), each with an interface mock-up that animates the moment its section enters the frame.
WhyThe in-view class does the work: set by the IntersectionObserver at 168 px of visibility, it serves as a CSS hook to start each screen's demo (rows appearing, bar filling). Vertical Snap (fx-0593), pure CSS, emits no usable signal; Snap + Progress (fx-0597) lacks the scroll-snap-stop: always meant to stop a demo from being skipped mid-way. The 700 ms cascade covers the end of the snap (271 ms).
SettingsContainer and sections raised to 316 px under a 44 px navigation bar; sections laid out in a row (text left, mock-up right); the emoji becomes a step number; the mock-up is animated by .sn-smooth-section.in-view .sc1-… rules (600 to 900 ms) added on top of the code's cascade.
Rules of a strategy game to read before playing — Smooth Snap example 2

② Rules of a strategy game to read before playing

WhenWelcome screen of a digital board game: four fundamental rules, a “Rule n / 4” counter, the game only opens after the last one.
WhyThe mandatory stop is the mechanism sought: with scroll-snap-stop: always, a fling is meant to land on each rule instead of crossing three — which fx-0593, without the property, does not promise. The 700 ms fade on each entry marks the reading, and the in-view class feeds the counter and the “Read” stamp. Verify on the target device: on the headless bench, the stop changed nothing.
Settings320 px container under a 40 px header, green felt background (#0b1a12 → #142a1c), emojis kept, rotating “Read” stamp driven by .in-view, counter updated by a MutationObserver on the class.
Consent by tracker category — Smooth Snap example 3

③ Consent by tracker category

WhenPrivacy portal: four categories (Essential, Analytics, Marketing, Partners) scroll on the right, a left column ticks “seen” for each, the save button activates at 4/4.
WhyThe only snap in the batch to combine a mandatory pass and a JavaScript callback: adding the in-view class is an observable event (MutationObserver or a modified callback) that records the display of each category. Pure CSS snaps offer nothing of the kind. Caution: the class is removed on exit (from 112 px of displacement) — “seen” must be stored on the first addition, not read live.
Settings360 × 360 px container next to a 180 px column, sections with a switch, boxes ticked on the first in-view (stored), button disabled until all four have been displayed.

How it works

Anatomy: #snSmoothContainer (.sn-smooth-container, 100% × 280px, overflow-y: auto, scroll-snap-type: y mandatory, scroll-behavior: smooth) holds four .sn-smooth-section blocks of 280px — exactly the container's height — with scroll-snap-align: start and scroll-snap-stop: always: 1,120 px of content, 840 px of travel, three stops. Each section is a centered flex column (gap: 12px, padding: 20px) on the same gradient #0f0f23 → #1a1a3e — nothing visually separates two sections except their content: an emoji .sn-smooth-icon (2rem), an h4 (.9rem, 700) and a p (.72rem, white at 60%), all three at opacity: 0 and translateY(20px) at rest. The .sdc-scroll-hint cue (“Scrollez ↓”, 10.4 px, white at 30%, 2 s pulse) sits outside the container, absolutely positioned in .demo-preview (position: relative).

The script (IIFE, ES5) grabs the container by getElementById — one instance per page — and creates an IntersectionObserver with root: container and threshold: 0.6; the callback adds in-view to every isIntersecting section and removes it from the others. CSS does the rest: .sn-smooth-section.in-view .sn-smooth-icon, h4 and p go to opacity: 1 and translateY(0) with transitions of .5s, .5s .1s and .5s .2s — a 700 ms cascade (measured: icon at 1 around 500 ms, title around 600 ms, text at 0.999 at 700 ms). A 0.6 threshold on 280 px is 168 px: a section gains the class as soon as 168 px of it are visible and loses it below that. During a transition, the leaving section loses it after 112 px of displacement and the arriving one only gains it at 168 px: over 56 px, no section is in-view (measured: positions 113 to 167 px). A smooth 280 px scrollTo settles in 271 ms; the class leaves section 1 at 134 px (79 ms) and reaches section 2 at 180 px (96 ms), i.e. 17 ms without content. On load, the observer's first callback sets the class on section 1 (measured 15 ms after DOMContentLoaded): the in-view hard-coded in the sold HTML is redundant as long as the script runs.

Snapping, measured in headless Chromium (macOS): a wheel notch of 100 to 139 px does not cross the stop — the container moves up then returns to 0 in 180 ms (mandatory picks the nearest point, half of 280); 141 px are enough to reach 280; Arrow down, Page down and Space each move one section, End jumps straight to 840. A single 600 px wheel event settles at 560 (two sections) and a synthesized 600 px touch fling at 840 — exactly the positions of Vertical Snap (fx-0593), which has no scroll-snap-stop. The property therefore blocked nothing on this bench: the specification reserves it for inertial scrolls (touch momentum, animated wheel) that the automation does not reproduce, and it never constrains the scrollbar, the End key or scrollTo() — verify on the target device before making it a guarantee. The rest of the code: no prefers-reduced-motion rule, no disconnect() (the observer is local to the IIFE), two dead rules .smooth-snap-section.in-view … and .smooth-icon inherited from an earlier version (classes absent from the HTML), an inline style="font-family: Inter…" on all four h4 although Inter is never loaded, and French texts shipped without accents (“Demarrez”, “idees”, “Precision”, “detail”, “resultat”).

Accessibility

  • prefers-reduced-motion not handled: measured with the preference on, the fade plays unchanged (opacity from 0.02 to 1 between 123 and 615 ms after entry), scroll-behavior: smooth stays active and the cue keeps pulsing. Add @media (prefers-reduced-motion: reduce) { .sn-smooth-container { scroll-behavior: auto } .sn-smooth-section .sn-smooth-icon, .sn-smooth-section h4, .sn-smooth-section p { transition: none } .sdc-scroll-hint { animation: none } }: content then appears at once on entry, and snapping — which is not an animation — remains.
  • Keyboard: measured in Chromium, Tab focuses the container without any tabindex (keyboard-focusable scrollers), then Arrow down, Page down and Space each advance one section (280 px) and End goes to the last one (840). That implicit focus does not exist in every browser: set tabindex="0", an aria-label and a visible focus ring (:focus-visible { outline: 2px solid #818cf8 }) on .sn-smooth-container.
  • Screen readers: hidden content is hidden by opacity: 0, which removes nothing from the accessibility tree — all four sections are read in order, snap or not, with no aria-hidden to add. The in-view class, however, is announced nowhere: if “has been seen” carries functional meaning (consent, rule read), do not infer it from the class alone. The h4 elements are level-4 headings with no h1–h3 above them: align the level with your page.
  • Contrast (computed on the #0f0f23 → #1a1a3e gradient): white title 16.7 to 18.9:1, paragraph rgba(255,255,255,.6) 6.7 to 7.2:1 — compliant, but the paragraph is 11.5 px. The “Scrollez ↓” cue (rgba(255,255,255,.3) on #0a0a0f) measures 2.6:1 at 10.4 px: it is an instruction, not decoration — raise the opacity to .5 (5.3:1) or .6 (7.3:1).
  • Integration: the container's height and the sections' height must stay equal (the 0.6 threshold and the snap depend on it); the cue is positioned relative to .demo-preview — without that position: relative parent, it drops to the bottom of the next positioned ancestor. A 280 px mandatory block captures the wheel on hover: three notches before handing back to the page. In an application, keep a reference to the observer for disconnect(), impossible today (local variable).

Browser compatibility

ES5 JavaScript (var, function) and classic CSS: IntersectionObserver with root, scroll-snap-type, scroll-snap-align, scroll-snap-stop, scroll-behavior, flex gap, transitions. No animation-timeline: unlike scroll-driven effects in native CSS, this one works in Firefox (103+ for scroll-snap-stop, 63+ for everything else). Zero dependencies.

Chrome 84+✓ Full
Firefox 103+✓ Full (63–102: without the mandatory stop)
Safari 15.4+✓ Full
Edge 84+✓ Full
Mobile iOS 15.4+✓ Full
Android Chrome✓ Full

Without JavaScript, snapping works (pure CSS) but only section 1, whose in-view class is written in the HTML, shows its content: sections 2 to 4 stay empty (measured: opacity 0 on their titles). Without scroll-snap-stop (Firefox < 103, Safari < 15), snap and fade work, only the mandatory stop disappears. Under prefers-reduced-motion, nothing changes.

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="sn-smooth-container" id="snSmoothContainer">
    <div class="sn-smooth-section in-view">
      <span class="sn-smooth-icon">🚀</span>
      <h4>Lancement</h4>
      <p>Démarrez avec style et élégance</p>
    </div>
    <div class="sn-smooth-section">
      <span class="sn-smooth-icon">💡</span>
      <h4>Innovation</h4>
      <p>Des idées qui transforment</p>
    </div>
    <div class="sn-smooth-section">
      <span class="sn-smooth-icon">🎯</span>
      <h4>Précision</h4>
      <p>Chaque détail compte</p>
    </div>
    <div class="sn-smooth-section">
      <span class="sn-smooth-icon">✨</span>
      <h4>Excellence</h4>
      <p>Le résultat final parfait</p>
    </div>
  </div>
  <span class="sdc-scroll-hint">Scrollez ↓</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
Height (CSS — .sn-smooth-container and .sn-smooth-section) 280px / 280px Both must stay equal: the section fills the frame and the 0.6 threshold is computed on this height (168 px). 100vh on both sides for full screen; the “Scrollez ↓” cue stays 8 px from the bottom of .demo-preview.
Observer threshold (JS — threshold: 0.6) 0.6 (168 px visible) When the class flips. 0.5 removes the 56 px dead band (leave and arrive at 140 px); 0.9 only reveals content once the snap has nearly settled — the cascade starts later but no longer overlaps the motion.
scroll-snap-stop (CSS — .sn-smooth-section) always normal restores fx-0593's behavior: a fling may cross several sections. No effect on the keyboard, the scrollbar or scrollTo() — and no difference measured on the headless bench.
Cascade (CSS — transition: .5s, .5s .1s, .5s .2s) 700 ms in total Shared duration and icon → title → text offsets. .3s / .05s / .1s gives 400 ms, closer to the snap duration (271 ms); the exit uses the same values, so the leaving section fades at the same speed.
Amplitude (CSS — transform: translateY(20px)) 20 px Rise distance, identical for all three elements. 0 for a pure fade; 40px for a stronger entrance; a scale(.96) added at rest makes a slight zoom.
scroll-behavior (CSS — .sn-smooth-container) smooth Only concerns programmatic and keyboard moves (measured: a wheel notch is not animated by this property). auto under prefers-reduced-motion, or if you drive the container from buttons and want a clean jump.
Number and background of sections (HTML — .sn-smooth-section blocks; CSS — background) 4, a single #0f0f23 → #1a1a3e gradient Add or remove blocks: the observer takes every .sn-smooth-section in the container. All four share the same background; one :nth-child per section (as in fx-0593) makes the stop change visible even before the fade.
Script target (JS — getElementById('snSmoothContainer')) one id, one instance For several containers, replace with document.querySelectorAll('.sn-smooth-container').forEach(init) and drop the id; keep each observer's reference for disconnect().

FAQ

On paper, a ban on crossing a stop: the specification asks that an inertial scroll (touch momentum, animated wheel spin) stops at every section marked always. On the bench, nothing: a 600 px wheel → 560 (two sections), a 30 × 40 px trackpad burst → 840, a synthesized 600 px touch fling → 840, the End key → 840 — the very same positions, to the pixel, as fx-0593 without the property. Headless Chromium does not reproduce a real inertial gesture, and the property never acts on the scrollbar, the keyboard or scrollTo() anyway. The certain difference between the two effects lies elsewhere: fx-0593 is pure, silent CSS; fx-0598 adds and removes an in-view class at 168 px of visibility, with a 700 ms fade — that signal, usable from CSS or JavaScript, is what justifies choosing it.
The in-view class is the hook. In CSS: .sn-smooth-section.in-view .my-mockup { … } starts any transition inside the section (a bar filling, rows sliding in). In JavaScript: a MutationObserver with attributeFilter: ['class'] on the container, or one line added in the observer's callback (entry.target.dispatchEvent(new CustomEvent('sn:enter'))). Mind the symmetry: the callback removes the class as soon as the section drops under 168 px visible, i.e. after 112 px of displacement — for an “already seen” state, store the first addition (or call obs.unobserve(entry.target) on first entry) instead of reading the class live.
Because their content sits at opacity: 0 until the in-view class arrives, and the script fails silently — no console error in any of the four measured cases. Either the id changed (getElementById('snSmoothContainer') returns null and the IIFE exits at if (!container) return), or the script is placed before the HTML (it runs immediately, the container does not exist yet), or two containers share the id (only the first is observed), or JavaScript is absent: section 1 shows thanks to the in-view written in the HTML, the others never. Check HTML-then-script order and id uniqueness, and treat the hard-coded class as a fallback, not as the initial state.