Card Snap Carousel
Six 200 × 240 px cards in a 280 px track set to scroll-snap-type: x mandatory, each one snapped to the center (scroll-snap-align: center): the neighbors peek from both edges as long as the track is narrower than 624 px. No JavaScript — and scroll-behavior: smooth only eases scripted scrolling, not the wheel.
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-carousel-container is the track: width: 100%, height: 280px, overflow: auto hidden (scrolls in x, never in y), scroll-snap-type: x mandatory, scroll-behavior: smooth, flex with align-items: center, gap: 12px and padding: 0 12px. Inside, six .sn-carousel-card of 200 × 240 px (flex-shrink: 0, border-radius: 14px, 4% white background, 1 px border at 8%), each carrying scroll-snap-align: center. A card = a 100 px .sn-carousel-img band (number 01 to 06 at 1.5 rem / 800, a 135° gradient per nth-child: indigo → violet → fuchsia → rose → amber → green → cyan) and a .sn-carousel-body (12 px padding, flex column, 4 px gap: h4 .82 rem, p .68 rem at 50% white, .sn-carousel-tag .65 rem violet pushed to the bottom by margin-top: auto). Total track: 12 + 6 × 200 + 5 × 12 + 12 = 1,284 px. The shipped HTML ends with .sdc-scroll-hint “Scrollez →”, absolutely positioned 8 px from the bottom of .demo-preview, its opacity pulsing from .3 to .7 every 2 s (sdcHintPulse) — a catalogue fragment shared by 44 effects of the category, not a part of the carousel. Zero JavaScript.
The snap arithmetic: the center of card n sits at 112 + 212 × (n − 1) px from the start of the track; the resting position is that center minus half the visible width, clamped between 0 and 1,284 − width. Measured in Chromium: a 524 px track → six stops (0, 62, 274, 486, 698, 760), cards 2 to 5 centered with 150 px of each neighbor visible, cards 1 and 6 stuck to the edge; a 384 px track (mobile) → 80 px of neighbor; a 728 px track → only 40 px; a 784 px track → four stops for six cards (0, 144, 356, 500): cards 1-2 share position 0, cards 5-6 share the end of travel, none of the four is ever centered. The symmetrical “peek” only exists below 624 px of track — beyond that, (width − 200) / 2 − 12 exceeds 200 px and the neighbor is fully visible.
scroll-behavior: smooth only acts on scripted scrolling: scrollLeft = 274 measured 0 → 3 → 14 → 42 → 105 → 180 → 207 → 226 → 239 → 254 → 260 → 265 at 16 ms intervals, about 200 ms; with auto, the same write is instant (274 on the first sample). With the wheel, the readings are identical with or without the property: one horizontal notch of 120 px carries the track to 119, then the snap pulls it back to 62 in about 180 ms — that is the browser's easing, not the CSS. The vertical wheel does nothing (three notches, 0 px): without a trackpad, it is Shift + wheel or the scrollbar. A 600 px flick jumps from 0 to 500, the end of travel, without stopping at the cards in between: no scroll-snap-stop: always. On the keyboard in Chromium, Tab focuses the track and Right Arrow advances one stop (0 → 144). No console errors.
Accessibility
- prefers-reduced-motion is absent from the code: measured identically under the preference — scripted scrolling still goes through eleven intermediate values (1, 13, 41, 102…) and the
sdcHintPulseanimation staysrunning. Add@media (prefers-reduced-motion: reduce) { .sn-carousel-container { scroll-behavior: auto } .sdc-scroll-hint { animation: none } }. The snap itself is not an animation: it stays, and that is desirable. - Keyboard: the track has no
tabindex. Chromium makes it focusable by default (measured: Tab →.sn-carousel-container, Right Arrow = one stop), Firefox too, Safari does not. Settabindex="0",role="region"and anaria-label, then a:focus-visiblestyle — the CSS has none. The cards are not interactive; if you put links in them, each off-screen card stays tabbable and the browser brings it into view, snap included. - Contrast on the card background (#141419 once the 4% white sits on #0a0a0f): white title 18.4:1,
pat 50% white 5.3:1,#a78bfatag 5.8:1 — but at 10.9 px and 10.4 px, too small for useful text: go to 12-13 px. White numbers on the gradients: 4.5:1 (card 1) down to 2.2:1 (card 5, amber → green) and 2.3-2.4:1 (card 6), below the 3:1 threshold even for 24 px text. The “Scrollez →” hint swings from 2.6:1 to 9.7:1 at 10.4 px, and it is written in French in the HTML. - Mouse without a trackpad: vertical wheel = 0 px (measured). The code has neither previous/next buttons nor a position indicator: add two buttons calling
scrollBy({ left: ±212 })— thescroll-behaviorproperty will ease the move. Nothing tells a screen reader there are six cards: a<ul>orrole="list"/role="listitem". The scrollbar is not hidden (noscrollbar-widthor::-webkit-scrollbarrule): on Windows it takes about 17 px of the 280 px track, below the cards. It is the component's only position cue — keep it or replace it with dots.
Browser compatibility
CSS only: scroll-snap-type: x mandatory / scroll-snap-align: center (2018 syntax), two-value overflow, scroll-behavior: smooth, flex gap and width: fit-content. No animation-timeline: this is native scrolling, it works in Firefox — unlike CSS scroll-driven animations, which Firefox does not render in any version. Zero dependencies, zero script.
Chrome 69-83: full snap and easing but flex gap ignored, the cards touch. Firefox 68-93: everything works except width: fit-content, the tag pill stretches across the card body. Safari 11-15.3: native snap, but scripted scrolling is instant (scroll-behavior ignored); wheel and finger are unchanged. Without scroll-snap at all: a free horizontally scrolling row, with no stop on the cards.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="sn-carousel-container">
<div class="sn-carousel-card">
<div class="sn-carousel-img">01</div>
<div class="sn-carousel-body">
<h4>Title</h4>
<p>Description</p>
<span class="sn-carousel-tag">Tag</span>
</div>
</div>
<!-- × 6 cards -->
</div>
<span class="sdc-scroll-hint">Scroll →</span>
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 |
|---|---|---|
| Card width (CSS — .sn-carousel-card width) | 200px | With gap: 12px, the pitch between two stops is width + 12, and the visible share of each neighbor is (track − width) / 2 − 12. At 260 px in a 540 px track: 128 px of neighbor instead of 158. |
| scroll-snap-align (CSS — .sn-carousel-card) | center | start snaps the active card to the left edge — add scroll-padding-left: 12px on the track to honor the padding — and makes the first card reachable, but the last one no longer snaps. |
| scroll-snap-type (CSS — .sn-carousel-container) | x mandatory | x proximity only snaps near a stop and leaves free scrolling in between. To forbid skipping cards (measured: a 600 px flick goes from 0 to the end of travel), add scroll-snap-stop: always on .sn-carousel-card. |
| gap + padding (CSS — .sn-carousel-container) | 12px / 0 12px | Keep them equal: the first and last card then get the same margin as the space between two cards. The padding also sets the resting position of card 1 (12 px from the edge). |
| Heights (CSS — .sn-carousel-container height / .sn-carousel-card height) | 280px / 240px | 20 px free above and below the cards; that is where the scrollbar sits when the system shows one. At 260 px of track, it eats into the cards. |
| scroll-behavior (CSS — .sn-carousel-container) | smooth | Only concerns scrollLeft, scrollTo, scrollBy and anchors (about 200 ms measured); wheel and finger do not depend on it. Switch to auto under prefers-reduced-motion. |
| Band gradients (CSS — .sn-carousel-card:nth-child(1…6) .sn-carousel-img) | 6 gradients at 135°, indigo → cyan | Six rules for six cards: a seventh card has no background. Write nth-child(6n+1)… to cycle, or a single color for every band. |
| “Scrollez →” hint (HTML — .sdc-scroll-hint) | bottom: 8px, opacity .3 ↔ .7 over 2 s | Positioned against the nearest positioned ancestor (.demo-preview in the shipped code). Translate it, replace it with buttons or remove it: it is decorative and its contrast drops to 2.6:1 at the low point of the pulse. |
FAQ
scrollLeft, centering card 6 would overshoot the end of travel. Measured in a 784 px track: cards 1 and 2 share stop 0, cards 5 and 6 share stop 500 — four positions for six cards. The fix is padding that creates the missing room: .sn-carousel-container { padding: 0 calc(50% - 100px) } (half the track minus half a card). Every card then becomes centerable, first and last included, and the number of stops equals the number of cards whatever the width.sn- prefix, same track (overflow: auto hidden, x mandatory, smooth, 280 px), but fx-0594 uses sections at min-width: 100% snapped to start: one panel per stop, nothing of the neighbors, always as many stops as sections. fx-0595 fixes 200 px cards snapped to the center: the neighbors peek, and the number of stops depends on the width (six at 524 px, four at 784 px). Pick fx-0594 for a sequence read one message at a time, fx-0595 to browse and compare several short items.track.addEventListener('wheel', e => { if (!e.deltaY) return; e.preventDefault(); track.scrollBy({ left: e.deltaY }); }, { passive: false }) — scroll-behavior: smooth eases the move and the mandatory snap ends on a card. The price: the page no longer scrolls vertically while the pointer is over the track, including at the end of travel; test scrollLeft against 0 and scrollWidth − clientWidth before canceling the event. Two scrollBy({ left: ±212 }) buttons remain the most readable solution.