Scroll✨ Premium

Snap Gallery with Progress

Five screens under y mandatory snap and a 3 px rail filled by a named scroll timeline: 0 → 100% of the travel, continuous between two screens, zero JavaScript.

CSSscroll-snapProgress

Updated

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

Escape game rooms, one per screen — "Our rooms" page of a booking site — Snap Gallery with Progress example 1

① Escape game rooms, one per screen — "Our rooms" page of a booking site

WhenThe Laboratory, The Crypt, The Submarine, The Asylum, The Casino: each room full frame with its level, duration and number of players, a "Book" button at the top, the rail on the left filling from one room to the next.
WhyFive rooms are discovered in order and the continuous rail shows the move from one to the next during the snap, with no script recalculating an index — fx-0597 (published) lights an active dot and adds a click on each dot, pointless here: the dots only count.
SettingsFrame at 100% of the scene (360 px), rail at 14 px, 4 px wide, #facc15 → #f97316 gauge; watermark number at 120 px, 30 px label, level as five squares; tabindex="0" and aria-label on the area. The screens advance on their own until the first gesture: a demonstration device, not a capability of the code.
Works of a virtual exhibition — exhibition page of an art gallery — Snap Gallery with Progress example 2

② Works of a virtual exhibition — exhibition page of an art gallery

WhenOne work per screen — watermark number, title, medium and dimensions, artist — and a 3 px rail, ink on cream, that says where you are in a selection of five pieces.
WhyA calm reading: one work at a time, a discreet gauge, no clickable dot to skip, nothing moves outside the gesture. fx-0594 Horizontal Snap (published) imposes a sideways sweep and arrows, a slideshow rhythm that is not an exhibition's.
Settings#f3efe7 background, #1c1917 text; rail at 12 px, black track at 10%, solid #1c1917 gauge, 6 px dots; number in Georgia 96 px at 9%, 24 px serif title, work figured by a white 186 × 236 px frame. Rail, dots and area stay under the frame that carries timeline-scope.
Cleaning procedure for a filling machine — operator's tablet at the end of a production run — Snap Gallery with Progress example 3

③ Cleaning procedure for a filling machine — operator's tablet at the end of a production run

WhenPre-rinse, alkaline wash, intermediate rinse, disinfection, final rinse: the five steps of the procedure in their fixed order, one per screen, with the product, temperature, duration and instruction of each; header with the line, the machine and the batch, a "Validate the step" button at the bottom, rail on the left edge of the tablet screen filling from one step to the next.
WhyOrder is the rule: no disinfection without the wash before it. The continuous rail says where you are without offering a shortcut — no clickable dot, nothing lighting up — and it glides during the snap itself, showing the move from one step to the next at the moment of the gesture. fx-0597 Snap + Progress (published) lights an active dot and turns every dot into a link that jumps to its section: here, jumping from the pre-rinse to the disinfection is exactly what the procedure forbids, and an active index recomputed by script adds nothing to the five fixed dots that count the steps.
Settings236 px frame inside a 512 × 332 px tablet, 944 px of travel; rail at 12 px, 4 px wide, #38bdf8 → #4ade80 gauge, 6 px dots; scroll-snap-stop: always on the steps so a single gesture cannot skip one; each step on its own gradient with its parameters as chips, amber chip for PPE; tabindex="0" and aria-label on the area. In production, height: 100dvh minus the header and the action bar.

How it works

The HTML sold: the .demo-preview frame (#0a0a0f background, min-height: 300px), then .snap-gallery-wrapper — 100% × 300 px, position: relative, timeline-scope: --snapScroll — which holds the .snap-progress-bar rail (absolute, 10 px from the left edge, 3 px wide, white track at 10%) and its .snap-progress-fill gauge (#6366f1 → #d946ef gradient, height: 0%), the .snap-progress column of five .snap-dot (8 px, 10 px from the right edge) and the .snap-gallery-container area: 100% × 100%, overflow: hidden auto, scroll-snap-type: y mandatory, scroll-timeline: --snapScroll y. Inside, five 300 px .snap-slide with scroll-snap-align: start, one 135° gradient each, a 48 px number at 15% opacity and a 13.6 px label. Under the frame, the .slr-scroll-hint hint "Scrollez entre les slides" bounces 4 px every 2 s. The JS field is empty; the .snap-dots rule matches no element. Measured on a blank page: 1,200 px of travel, no console error, no external resource.

The gauge never reads scrollTop: it is the @keyframes snapProgressGrow animation (height: 0% → 100%, linear, animation-fill-mode: both) whose clock is replaced by the scroll position. scroll-timeline: --snapScroll y declares a named timeline on the area (0 at the top, 1 at the last pixel of travel); animation-timeline: --snapScroll subscribes the gauge to it, animation-duration: auto leaves the duration to the timeline and animation-range: normal covers the whole travel. A named timeline is only visible to the element that declares it and to its descendants; the gauge sits in a sibling branch, and timeline-scope: --snapScroll on the shared frame is what brings it within reach. Without that line, measured in Chromium 151, the animation falls back to the document clock (duration 0 s, finished) and the gauge stays full. With it: 0, 75, 150, 225, 300 px of gauge on the five screens (scrollTop 0, 300, 600, 900, 1,200), at 1,280 as at 390 px wide, and a proportional value in between — 74 px at 296 px of scrolling in WebKit: the rail glides during the snap itself.

The snapping is the engine's: mandatory forbids any rest between two screens. Measured in Chromium, a 150 px wheel notch springs back to 0 and 160 px moves to screen 2 (you must pass half of 300 px in a single gesture); 900 px goes straight to screen 4, with no scroll-snap-stop. Firefox 153 and WebKit 26.5 advance one screen from 100 px, Firefox never more than one per gesture. No scroll-behavior: smooth: scrollTop = 600 set by script reads back 600 at once. The five dots are fixed — no active class, no click: they count the screens, the rail says where you are. The frame height is written in pixels because a percentage needs a parent with a defined height: with height: 100% under .demo-preview alone, the gallery measured 75 px, five 15 px strips.

Accessibility

  • prefers-reduced-motion absent from the code: measured under emulation, the gauge still follows the gesture (75 px on screen 2) and the hint keeps bouncing (running). The gauge only moves through the reader's gesture and is acceptable as is; the hint's bounce is permanent: add @media (prefers-reduced-motion: reduce) { .slr-scroll-hint { animation: none } }. Snapping is a resting position, not an animation.
  • Keyboard: measured, Chromium 151 and Firefox 153 give the area focus via Tab without tabindex; arrow down = 300, Page Down = 600, End = 1,200, the gauge follows. WebKit 26.5 does not focus a scrolling area without tabindex="0" (focus left on body, arrows without effect): add it, with an aria-label and a :focus-visible style. Rail and dots are decorative: aria-hidden="true" on .snap-progress-bar and .snap-progress.
  • Contrast: the white label holds everywhere, 5.0:1 at worst (on #b45309, screen 4). The number at 15% is 1.25 to 1.58:1: a watermark, unreadable by design — if it carries meaning, also write it in plain text. The hint at 40% is 3.8:1 on #0a0a0f. Components (3:1 threshold): dot border 2.4 to 5.0:1 depending on the background, rail track 1.1 to 1.4:1, #6366f1 gauge 3.6:1 on the first screen's indigo but 1.2:1 on the last screen's blue — darken the track on a light or blue background.
  • Screen readers: five div of two span, the number is read ("zero one") before the label; hide it or make the label a heading if the screens are sections. Nothing changes in the accessibility tree while scrolling: the progress is purely visual. The hint is hard-coded in French. On touch, overscroll-behavior: contain on the area keeps the page from being dragged along at the end of the travel.

Browser compatibility

CSS only, zero dependency: named scroll-timeline, animation-timeline, timeline-scope, animation-range, animation-duration: auto, scroll-snap-type. Snapping has existed everywhere since 2018; the gauge sets the floor: scroll-driven animations in Chrome and Edge 115, and timeline-scope, essential here, in Chrome and Edge 116; Safari and iOS 26 (measured in WebKit 26.5: same values as Chromium). Firefox: no version. Measured in Firefox 153: CSS.supports('animation-timeline: scroll()') returns false, the computed duration is 0s, snapProgressGrow runs on the document clock and finishes at once (finished, fill-mode: both): the gauge is full at 100% from the start and stays so, on every screen. Everything else is intact: five screens, snapping (100 px of wheel = next screen), dots, labels, numbers, no error. A Firefox visitor sees a gallery that snaps screen by screen under a full rail. Chrome and Edge 115 have the timeline but not timeline-scope: the gauge cannot reach it — measured without that line in Chromium 151, it stays full.

Chrome 116+✓ Full
Firefox✓ No version: gauge permanently full at 100%, screens, snapping and dots intact
Safari 26+✓ Full (measured in WebKit 26.5)
Edge 116+✓ Full
Mobile iOS 26+✓ Full (touch scrolling of the area)
Android Chrome 116+✓ Full

No @supports fallback in the code sold: where the timeline is missing, nothing is hidden or moved, the gauge is simply full. If a full rail bothers you more than an empty one, @supports not (animation-timeline: scroll()) { .snap-progress-fill { animation: none } } leaves it at height: 0%. Real progress on Firefox needs a scroll listener that writes the height — which is what Snap + Progress (fx-0597) does.

The code

HTML structure to paste into your page (the CSS + JS come with Premium):

index.html — structure
<div class="demo-preview">
  <div class="snap-gallery-wrapper">
    <div class="snap-progress-bar">
      <div class="snap-progress-fill"></div>
    </div>
    <div class="snap-progress">
      <div class="snap-dot"></div>
      <div class="snap-dot"></div>
      <div class="snap-dot"></div>
      <div class="snap-dot"></div>
      <div class="snap-dot"></div>
    </div>
    <div class="snap-gallery-container">
      <div class="snap-slide">
        <span class="snap-slide-number">…</span>
        <span class="snap-slide-label">…</span>
      </div>
      <div class="snap-slide">…</div>
      <div class="snap-slide">…</div>
      <div class="snap-slide">…</div>
      <div class="snap-slide">…</div>
    </div>
  </div>
  <span class="slr-scroll-hint">…</span>
</div>
Unlock the full code — €9.90/mo excl. tax

The full HTML + CSS + JS of Snap Gallery with Progress, ready to paste — plus all 811 effects. Or €299 once, lifetime access.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Frame height (CSS — .snap-gallery-wrapper { height: 300px }) 300 px (area and screens at 100%, 1,200 px of travel) Area and screens are at 100% and follow. 100vh (100dvh on mobile) for one screen per view, with the same height on .demo-preview. A percentage needs a parent with a defined height: measured with height: 100%, 75 px of gallery.
Number of screens (HTML — .snap-slide, .snap-dot; CSS — :nth-child(1) to (5)) 5 screens, 5 dots, 5 gradients A sixth screen has no background (measured: background-image: none): add its rule and a .snap-dot, nothing is generated. The gauge follows on its own: animation-range: normal covers the whole travel, 20% per screen instead of 25.
Rail (CSS — .snap-progress-bar, .snap-progress-fill) left 10 px, 3 px wide, white track at 10%, #6366f1 → #d946ef gauge left: auto; right: 10px to move it to the right, width: 6px to thicken it, a solid colour instead of the gradient. It can live anywhere under the frame that carries timeline-scope; taken out of that frame, it falls back to a fixed 100%.
Gauge progression (CSS — animation-timing-function, animation-range on .snap-progress-fill) linear, normal (0 → 100% of the travel) steps(4) makes the gauge jump per screen instead of gliding: measured 0, 0, 75, 75, 150 px at 0, 150, 300, 450, 600 px of scrolling. animation-range: 0% 80% fills it by the fourth screen.
Snapping (CSS — scroll-snap-type, scroll-snap-align; scroll-snap-stop to add) y mandatory / start, without scroll-snap-stop or scroll-behavior y proximity allows a rest between two screens if one can exceed the height. scroll-snap-stop: always on .snap-slide forbids skipping screens: measured without it, a 900 px notch goes from screen 1 to 4 in Chromium. scroll-behavior: smooth on the area if a button or an anchor drives it.
Dots (CSS — .snap-progress, .snap-dot) 5 × 8 px, white 30%, border 50%, 10 px from the right edge Decorative and fixed: no active state, no click. A dot that lights up or jumps to the screen needs JavaScript — that is Snap + Progress (fx-0597). Remove the block if the rail is enough, and the orphan .snap-dots rule with it.
Backgrounds, texts and hint (CSS — .snap-slide:nth-child(n), .snap-slide-number, .snap-slide-label, .slr-scroll-hint) 5 gradients at 135°, 48 px number at 15%, centred 13.6 px label, bouncing French hint Each screen is a centred flex box: flex-direction: column; align-items: flex-start and a padding for a card layout; on a light background, change the colour of .snap-slide. The hint is anchored to .demo-preview: delete the span, @keyframes hintBounce goes with it.

FAQ

Three causes. The browser: Firefox, every version, ignores the timeline and leaves the gauge at 100% (measured: duration 0 s, finished); Chrome/Edge 115 lack timeline-scope and the gauge cannot reach the timeline. Scope: a named timeline is only visible to the element that declares it (the area) and to its descendants; the gauge sits in the rail, next to it, and only sees it thanks to timeline-scope: --snapScroll on .snap-gallery-wrapper — measured without it, in Chromium: 100% at every position. If you move the rail, lift timeline-scope to the common ancestor; if you rename the timeline, rename it in all three places. Travel: without a defined height or without overflowing content, nothing scrolls and the gauge stays empty. An anonymous scroll() timeline solves nothing: it takes the gauge's nearest scrolling ancestor, the page, not the area.
Same snapping base (y mandatory, five sections of one height), everything else differs. fx-0597 is JavaScript: a scroll listener writes the width of a horizontal bar at the top, Math.round(scrollTop / height) lights the active dot and a click on a dot calls scrollTo({ behavior: 'smooth' }); it runs on Firefox. fx-0574 has not a single line of script: a vertical rail driven by scroll-timeline / animation-timeline, fixed dots, no click; Chrome/Edge 116+, Safari 26+, full rail on Firefox. fx-0574 when script is not possible or not wanted (locked WebView, CSP without inline script, static generator) and the audience is on Chrome or Safari; fx-0597 when Firefox matters, when the dots must be clickable or when the progress must be read by the rest of the page — a number in JavaScript, not a height in CSS.
A percentage needs a parent with a defined height: under .demo-preview alone (flex, min-height, no height), height: 100% resolves to auto and the gallery measured 75 px, hence the hard-coded 300 px. For one screen per view, give the frame height: 100vh (100dvh on mobile): area and screens at 100% follow, the travel becomes four times the height and the gauge realigns on its own. Keep the area as the scroller: it is the one carrying the timeline. To scroll the page itself, move scroll-snap-type and scroll-timeline: --snapScroll y to html, switch the rail to position: fixed (everything descends from html, timeline-scope becomes unnecessary) and plan scroll-padding-top under a fixed header. In every case, proximity rather than mandatory as soon as a screen can exceed the view.