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.
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



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 withouttabindex="0"(focus left onbody, arrows without effect): add it, with anaria-labeland a:focus-visiblestyle. Rail and dots are decorative:aria-hidden="true"on.snap-progress-barand.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,#6366f1gauge 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
divof twospan, 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: containon 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.
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):
<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>
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 / property | Default | Effect |
|---|---|---|
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
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.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..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.