Snap + Progress
Five 280 px sections under mandatory scroll-snap inside a 280 px container; on every scroll event the JS writes the width of a 3 px bar (0, 25, 50, 75, 100% at the five stops) and activates the dot whose index is Math.round(scrollTop / 280); clicking any of the five dots calls scrollTo({ behavior: 'smooth' }). 1.3 KB of JS, no loop, no global listener.
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
.sn-progress-wrapper (position: relative, 100% × 280 px) holds three things. The track .sn-progress-bar-track (absolute at the top, 3 px, white at 10%, z-index: 5) with its fill #snProgressBarFill: a 90° gradient #6366f1 → #d946ef, width: 0%, transition: width .3s. The column #snProgressDots (absolute, top: 10px; right: 8px, vertical flex, gap: 6px): five 8 px div.sn-progress-dot, white at 20%, cursor: pointer, transition: .3s; the .active class turns one #8b5cf6 and scale(1.3) (10.4 px measured). And the scroller #snProgressContainer (100% × 280 px, overflow-y: auto, scroll-snap-type: y mandatory, scroll-behavior: smooth) stacking five 280 px .sn-progress-section with scroll-snap-align: start, centered flex, padding: 20px, each with its own 135° gradient (#1e1b4b → #312e81 for the first, #7c3aed → #8b5cf6 for the last), a 0.9 rem h4 (with an inline font-family: Inter… in the HTML), a 0.72 rem p in white at 60% and a 3 rem .sn-section-number watermark at 8% white. Measured: scrollHeight 1400, clientHeight 280, a 1120 px run. The .demo-preview prelude wrapping it all supplies box-sizing: border-box for every descendant and the “Scroll ↓” hint pulsing every 2 s.
A single scroll listener on the container does all the work, with neither requestAnimationFrame nor IntersectionObserver. It computes progress = scrollTop / (scrollHeight − clientHeight) × 100 and writes it to barFill.style.width; then currentSection = Math.round(scrollTop / clientHeight) and toggles .active on the dot of that index. Measured at the five rest positions: 0, 25, 50, 75 and 100% — the percentage is a position along the run, not a section count: the first section reads 0% and only the last reaches 100%. The dot switches mid-section (139 px → dot 1, 141 px → dot 2). Because the bar has a 0.3 s transition, it lags behind the value: 80 ms after a wheel step the style says 51% while the rendered width measures 28% (209.7 px of 744).
Snapping is done by the CSS (mandatory), not by the JS. Measured in Chromium 151: a 60 px wheel step moves 42 px then returns to 0 (the bar shows 3.75% then 0%); 120 px → 85 px then back; 200 px → 280 px, the second section; 300 px = exactly one section per step; past the end, 1120 px and 100% hold. Clicking a dot calls container.scrollTo({ top: index × clientHeight, behavior: 'smooth' }): the easing is the browser's, measured over 840 px (dot 1 → dot 4) at 27, 192, 505, 671, 744, 789, 817, 834, 840 px at 50 ms intervals, about 450 ms; 280 px in about 400 ms. The scroll events fired during that glide keep bar and dots moving continuously. The only listeners are the container's and the five dots'; they die with the DOM, nothing is attached to window. The shipped CSS does, however, carry 18 rules foreign to the effect (.customizer-panel, .code-tab, .favorite-btn…), 1.5 KB of 4.2, that target nothing.
Accessibility
- prefers-reduced-motion is absent from the code. Measured under
reduceemulation:scroll-behaviorstayssmooth(click on dot 5: 1120 px glided in about 550 ms), the 0.3 s transitions remain, the “Scroll ↓” pulse keeps running (animation-play-state: running). Add@media (prefers-reduced-motion: reduce) { .sn-progress-snap-container { scroll-behavior: auto } .sn-progress-bar-fill, .sn-progress-dot { transition: none } .sdc-scroll-hint { animation: none } }; the JSbehavior: 'smooth'must also become'auto'under that preference, otherwise it forces the glide regardless of the CSS. - The five dots are empty
divs: norole, no name, notabindex—focus()is refused (measured), they are invisible to keyboard and screen readers, and the 8 px target (10.4 px active) is far below the 24 px minimum of WCAG 2.5.8. Replace them with<button type="button" class="sn-progress-dot" data-index="1" aria-label="Section 2 of 5">inside a<nav>, setaria-current="true"together with.activeand enlarge the hit area withpadding: the JS, which readsdata-indexand toggles a class, works unchanged. - Keyboard scrolling works, through the container. Measured in Chromium 151: Tab reaches the container (a scroller with no focusable child), and after a click inside a section, ↓, Page Down, Space, End and Home move exactly one section (280, 560, 840, 1120, 0 px), bar and dots following. On older browsers, set
tabindex="0"on.sn-progress-snap-containerwith a visible focus ring. The bar is a mutediv: give itrole="progressbar"witharia-valuenowupdated in the listener, oraria-hidden="true"plus a visually hidden “Section 2 of 5” text refreshed in the same place. - Contrast measured on rendered pixels. Active dot
#8b5cf6: 2.97:1 on section 1, 1.87:1 on section 3, 1.07:1 on section 5 — the background there is#8854f4, same hue: the position marker vanishes on the last section; inactive dots 1.4 to 1.8:1 everywhere. The bar fill at 100% sits over section 5 at 1.05:1 (#6366f1) and 1.22:1 (#d946ef), computed. Text: whiteh4from 16:1 down to 4.2:1 (section 5, 14.4 px bold: under 4.5:1);pat 60% white 6.5:1 on the first, 3.5:1 from the fourth, 2.5:1 on the last. A white active dot (≥ 4.2:1 on all five backgrounds), a 1 px outline on inactive ones and apat 85% fix most of it.
Browser compatibility
ES5 code (var, function, forEach); requires scroll-snap-type / scroll-snap-align (final specification), scroll-behavior: smooth, Element.scrollTo() with an options object, classList.toggle(name, force) and linear-gradient. No IntersectionObserver, no requestAnimationFrame, no animation-timeline: bar and dots are driven by the classic scroll event, which includes Firefox, which supports native CSS scroll-driven animations in no version at all. Zero dependencies.
Without JavaScript, snapping and smooth scrolling remain (CSS) but the bar stays at 0%, the first dot stays active (state written in the HTML) and the dots do nothing. Without scroll-snap (browsers older than 2018), the container scrolls freely; bar and dots still follow, since the JS only reads scrollTop, the dot switching mid-section. Safari 11 to 15.3 ignores scroll-behavior: a dot click jumps straight to the section.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="sn-progress-wrapper">
<div class="sn-progress-bar-track">
<div class="sn-progress-bar-fill" id="snProgressBarFill"></div>
</div>
<div class="sn-progress-dots" id="snProgressDots">
<div class="sn-progress-dot active" data-index="0"></div>
<div class="sn-progress-dot" data-index="1"></div>
<div class="sn-progress-dot" data-index="2"></div>
<div class="sn-progress-dot" data-index="3"></div>
<div class="sn-progress-dot" data-index="4"></div>
</div>
<div class="sn-progress-snap-container" id="snProgressContainer">
<div class="sn-progress-section">
<h4>Step 1</h4>
<p>Start your journey</p>
<span class="sn-section-number">01</span>
</div>
<div class="sn-progress-section">…</div>
<div class="sn-progress-section">…</div>
<div class="sn-progress-section">…</div>
<div class="sn-progress-section">
<h4>Step 5</h4>
<p>Mission accomplished!</p>
<span class="sn-section-number">05</span>
</div>
</div>
</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 — .sn-progress-wrapper, .sn-progress-snap-container, .sn-progress-section) |
280 px, three times | The JS takes container.clientHeight as the section height: the three values must stay equal. Measured with a 400 px container over 280 px sections: bar at 28 / 56 / 84 / 100%, dot 2 still active on section 3, a click on dot 5 stopped at 1000 px on dot 4. 100vh on all three gives full-screen sections. |
Number of sections (HTML — .sn-progress-section and .sn-progress-dot[data-index]) |
5 and 5 | One section added = one dot added with the next index, otherwise Math.round points to a dot that does not exist. The bar steps become 100 / (n − 1): six sections give 0, 20, 40, 60, 80, 100%. Plan the :nth-child(6) background rule. |
Snapping (CSS — scroll-snap-type: y mandatory) |
mandatory | proximity allows resting between two sections; the dot still switches at half height. Add scroll-snap-stop: always on the sections to forbid skipping one: measured from the top, a 600 px wheel step skips section 2 and lands on section 3 (560 px), and 1000 px go straight to the end of the run (1120 px). |
Bar (CSS — .sn-progress-bar-fill linear-gradient(90deg, #6366f1, #d946ef), .sn-progress-bar-track height: 3px) |
indigo → magenta, 3 px | The gradient is painted on the element and then clipped by its width: at 25% you only see the first quarter, the magenta only shows near 100%. For a constant colour, use a solid one; for the full gradient at any width, put background-size on the track rather than on the fill. |
Bar lag (CSS — transition: width .3s) |
0.3 s | Measured: 80 ms after a stop, the style announces 51% while the rendered width is 28%. 0s for a bar glued to the finger on touch; above 0.5 s it finishes its move after the snap. |
Dots (CSS — .sn-progress-dot 8 px, rgba(255,255,255,.2); .active #8b5cf6 + scale(1.3); .sn-progress-dots top: 10px; right: 8px; gap: 6px) |
8 px, column at the top right | 12 px plus 6 px of padding make a 24 px target; top: 50%; transform: translateY(-50%) centers the column. The active dot #8b5cf6 measures 1.07:1 on the fifth section's background: make it white, or change the last gradient. |
Dot click (JS — behavior: 'smooth') |
smooth | 'auto' jumps at once. The glide duration is the browser's (about 450 ms for 840 px measured in Chromium) and cannot be set; for a chosen duration, animate scrollTop yourself in requestAnimationFrame and switch the CSS to scroll-behavior: auto, otherwise the two glides stack. |
Section backgrounds (CSS — .sn-progress-section:nth-child(1…5)) |
five 135° gradients, from #1e1b4b to #8b5cf6 |
Each section has its own rule; a shared background on .sn-progress-section is enough if the five need not differ. The last two gradients drop the 60% p under 3.5:1. |
FAQ
progress = scrollTop / (scrollHeight − clientHeight) measures the position along the run: five sections make four intervals, hence 25% steps, and the first section is at 0 since nothing has scrolled yet. To show “1 of 5 = 20%”, compute (currentSection + 1) / dots.length × 100 in the listener instead, right after currentSection; the bar will then switch mid-section, like the dots, rather than glide continuously.scroll-snap-type: y mandatory snapping (fx-0598 adds scroll-snap-stop: always), but not the same JS. fx-0598 watches each section with an IntersectionObserver (root = the container, threshold: 0.6) and sets .in-view on the one entering: one event per section, meant to animate its content, with no indicator displayed. fx-0597 listens to scroll and derives two cues from it — the global percentage and the section index — plus click navigation. Scroll Snap Sections (fx-0584) has only the dots and divides by the constant 280 instead of clientHeight. The two mechanisms combine on one container: the observer to reveal content, this listener for the bar and dots.box-sizing: border-box comes from the .demo-preview * prelude, not from the sections' CSS, which have height: 280px and padding: 20px. Measured without the prelude: 320 px sections in a 280 px window, scrollHeight 1600, bar at 24.2 / 48.5 / 72.7 / 97%, no active dot at 1280 px (Math.round(1280 / 280) = 5, a sixth dot that does not exist), and a click on dot 5 stops at 1000 px. Add box-sizing: border-box to .sn-progress-section. Same origin for the font: the h4 carries an inline font-family: Inter…, the p inherits from the page (Times, measured on a blank page).