Scroll✨ Premium

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.

JSSnapProgress

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

Fire safety certification — five mandatory modules tracked by the bar — Snap + Progress example 1

① Fire safety certification — five mandatory modules tracked by the bar

WhenRegulatory CPF training (fire safety, five modules) taken on an intranet or e-learning platform: each module is a section, the certificate depends on completing the whole course.
WhyRegulatory training demands two pieces of information at once: where I am in the course (the bar: 0, 25, 50, 75, 100%) and which module is on screen (the active dot), with a way back to a module already read — one click on dot 2 returns there. fx-0584 has only the dots and measures nothing globally; fx-0593, pure CSS, gives no completion cue at all. An employee on module 3 reads 50%: the code measures the position along the run (scrollTop / 1120), not the number of validated modules, and the dots show the open module, not the finished ones — that status comes from the platform, not from the effect.
SettingsWrapper, container and sections at the player's height (the three are coupled, see Customize); dots raised to 12 px with an aria-label “Module n of 5”; bar at 4 px. To show a percentage of validated modules rather than a position, replace scrollTop / scrollHeight with (currentSection + 1) / dots.length in the listener.
Post-delivery satisfaction survey — five questions, one per screen — Snap + Progress example 2

② Post-delivery satisfaction survey — five questions, one per screen

WhenPost-purchase email opening a five-question page (delivery time, packaging, accuracy, recommendation, comment): one question per section, submission at the end.
WhySeeing how many questions remain reduces abandonment: the bar climbs in 25% steps and the dots bring the respondent back in one click to an answer to correct before sending. fx-0598, the smooth neighbour, watches each section entering with IntersectionObserver but shows no indicator: the respondent knows neither where they are nor how much is left.
SettingsLight theme: gradients replaced by #f8fafc, text #0f172a, track rgba(15,23,42,.08), 10 px dots in rgba(15,23,42,.18), active #6366f1 (4.5:1 on white), column centered vertically (top: 50%; transform: translateY(-50%)); scroll-snap-stop: always on the sections so a single wheel flick cannot skip a question.
Annual health check — five sections and a direct jump to the summary — Snap + Progress example 3

③ Annual health check — five sections and a direct jump to the summary

WhenPatient portal of a laboratory or health centre: results presented in five sections (Cardio, Metabolism, Endocrinology, Bone health, Summary), read mostly on a phone.
WhyA patient in a hurry wants the summary first: the fifth dot takes them there in one click (1120 px glided in about 450 ms, measured in Chromium), and the bar then tells them whether they have seen everything. fx-0596 (variable heights) has no direct navigation; neither does fx-0593. That direct access exists for keyboard and screen-reader users only if the dots become named buttons (“Summary”) — the shipped code does not do it.
SettingsDots as <button aria-label> carrying the section title, the summary's at 12 px to signal its role; fill #14b8a6 → #2dd4bf and active dot #2dd4bf on a slate #0b1220 background (9.3:1); values in font-variant-numeric: tabular-nums.

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 reduce emulation: scroll-behavior stays smooth (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 JS behavior: 'smooth' must also become 'auto' under that preference, otherwise it forces the glide regardless of the CSS.
  • The five dots are empty divs: no role, no name, no tabindexfocus() 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>, set aria-current="true" together with .active and enlarge the hit area with padding: the JS, which reads data-index and 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-container with a visible focus ring. The bar is a mute div: give it role="progressbar" with aria-valuenow updated in the listener, or aria-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: white h4 from 16:1 down to 4.2:1 (section 5, 14.4 px bold: under 4.5:1); p at 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 a p at 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.

Chrome 69+✓ Full
Firefox 68+✓ Full
Safari 15.4+✓ Full (11 to 15.3: snapping OK, dot click without glide)
Edge 79+✓ Full
Mobile iOS✓ Full (8 px dots hard to hit with a finger)
Android Chrome✓ Full (same)

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

index.html — structure
<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>
🔒 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 — .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

Because 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.
Same 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.
The 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).