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.
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: #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: smoothstays 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: settabindex="0", anaria-labeland 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 noaria-hiddento add. Thein-viewclass, however, is announced nowhere: if “has been seen” carries functional meaning (consent, rule read), do not infer it from the class alone. Theh4elements are level-4 headings with no h1–h3 above them: align the level with your page. - Contrast (computed on the
#0f0f23 → #1a1a3egradient): white title 16.7 to 18.9:1, paragraphrgba(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 thatposition: relativeparent, it drops to the bottom of the next positioned ancestor. A 280 pxmandatoryblock captures the wheel on hover: three notches before handing back to the page. In an application, keep a reference to the observer fordisconnect(), 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.
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):
<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>
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 |
|---|---|---|
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
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.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.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.