Horizontal Scroll
Six 180 × 140 px tiles on a 1,172 px track, inside an overflow: auto hidden container with scroll-snap-type: x mandatory: every gesture ends on a centred tile with its neighbours peeking from both edges — 1.4 KB of CSS, zero JavaScript, no animation: the finger or the wheel sets the pace. A .sb-horiz root carries the dark background and anchors the hint; the container is capped at 900 px so the track always overflows, even on a large screen.
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
A root, three levels and a label. .sb-horiz is the effect's root: position: relative (the hint's anchor), width: 100%, #0a0a0f background, white text, sans-serif font — pasted into a white page, it brings its own background instead of relying on the page's. Inside it, .sb-horiz-content is the scroll container: width: 100%, max-width: 900px, margin: 0 auto, height: 280px, overflow: auto hidden (horizontal scrolling, nothing vertical), scroll-snap-type: x mandatory, display: flex and align-items: center. It holds .sb-horiz-track, a flex track with gap: 12px and padding: 20px 16px, lining up six .sb-horiz-item of 180 × 140 px (flex-shrink: 0, 14 px radius, bold white digit at 1.2 rem, scroll-snap-align: center). Each carries its own 135° gradient in a style attribute, and the chain closes on itself: one tile's end colour is the next tile's start (#6366f1 → #8b5cf6 → #d946ef → #f43f5e → #f59e0b → #22c55e → #06b6d4). Track: 6 × 180 + 5 × 12 + 2 × 16 = 1,172 px. Underneath, .sdc-scroll-hint “Scrollez →”: absolute inside the root, 8 px from the bottom, 0.65 rem, white at 30 %, its opacity swinging from .3 to .7 every 2 s (sdcHintPulse). The JavaScript field is empty: the whole mechanism is the browser's.
Snapping. At the end of each gesture the browser settles the scroll on a snap position: a tile centred in the container (center). Positions outside the scroll range are clamped to the edges. Measured in Chromium with a 728 px container (744 px page): 444 px of travel and only four stops for six tiles — 0, 126, 318, 444; only tiles 3 and 4 ever centre, tiles 1-2 and 5-6 are only seen at the ends, in pairs. At 374 px (mobile): 798 px of travel, stops at 0, 111, 303, 495, 687, 798, tiles 2 to 5 centre. At 900 px, the container's cap on a desktop screen: 272 px of travel, stops at 0, 40, 232, 272 (scrollLeft = 100 reads back 40). One horizontal wheel notch (100 units) moves 117 px then settles at 126. Chromium also snaps programmatic writes: scrollLeft = 100 reads back 126, 50 reads 0 — the carousel cannot be left “in between”. By touch, each 150 px swipe lands on the next tile (111 → 303 → 495 → 687) and a fast 300 px flick runs to the end (798): mandatory dictates the stop, not the speed. No scroll-behavior: a scrollTo() is instant unless you pass behavior: 'smooth' (≈ 230 ms for 192 px in Chromium).
Why a root and a cap. The hint is position: absolute: it anchors to the nearest positioned ancestor, and without .sb-horiz that would be the window (measured on a version without the root: hint at y = 579 in a 600 px window while the carousel ended at y = 408). The root keeps it 8 px from the bottom of the carousel wherever the code is pasted, and it carries the dark background on which its 30 % white stays visible. The cap comes down to one property of scrolling: a container only scrolls if its content is longer than it is. Without max-width, a width: 100% container is 1,264 px wide on a 1,280 px page: the 1,172 px track fits entirely, scrollWidth = clientWidth, zero travel, nothing scrolls (measured). Capped at 900 px, travel is 272 px on that same page; in a column that is already narrower (the catalogue preview's 744 px, a phone's 374 px), the cap plays no part. Finally, the vertical wheel does nothing (0 px after three notches of 120): a horizontal container only listens to its own axis, and that is deliberate — hijacking the vertical wheel would trap the page's scrolling as it passes the carousel. With a mouse you need Shift + wheel, a side wheel, the scrollbar or the keyboard; trackpads and fingers scroll natively.
Accessibility
- prefers-reduced-motion is absent from the code. The only animation is the hint's opacity pulse (.3 ↔ .7, 2 s, infinite); measured under
reduce,sdcHintPulsekeeps running. Snapping itself is browser scrolling, not an animation, and withoutscroll-behavior: smooththere is nothing else to neutralise. Add@media (prefers-reduced-motion: reduce) { .sdc-scroll-hint { animation: none } }. - Keyboard. Measured in Chromium: Tab focuses the container (scroll areas with no focusable child are focusable since Chrome 130, and have long been in Firefox), Right Arrow advances one stop (126, then 318). Safari does not: set
tabindex="0",role="region"and anaria-label(“Destinations”, “Exams”) on.sb-horiz-content. If the tiles become links, drop thetabindex: Tab will scroll the focused tile into view. - Contrast. The “Scrollez →” hint is 10.4 px of white at 30 % under a pulsing opacity: 1.22:1 to 1.83:1 on
#0a0a0f(2.62:1 without the pulse) — never the required 4.5:1, and the text is hard-coded in French. The root imposes that dark background: the ratio does not depend on the host page. Either turn it into a real, legible instruction, or mark itaria-hidden="true"and put the instruction in the container'saria-label. The tiles' white digits: 4.47:1 on#6366f1but 2.15:1 on#f59e0b, 2.28:1 on#22c55e, 2.43:1 on#06b6d4— placeholders to replace; if text stays on top of an image, add a dark overlay. - Screen readers and affordance. Six
divwith no list semantics: switch toul/li(orrole="list") so the item count is announced; off-screen tiles stay in the accessibility tree, which is correct.overflow: autoleaves the native, unstyled scrollbar: on systems with classic scrollbars (Windows, macOS set to “always show”) it sits at the bottom of the container's 280 px — it is the only affordance mouse users get, so do not hide it (scrollbar-width: none) without adding buttons.
Browser compatibility
Requires scroll-snap-type / scroll-snap-align (2018 specification), the two-value overflow: auto hidden syntax and gap in a flex context — the last one sets the threshold. No JavaScript, no CSS variables, no animation-timeline: unlike this category's native CSS scroll-driven effects, this one works in Firefox.
Without JavaScript: nothing to lose, there is none. Safari 13.1 to 14.0 ignores flex gap: the tiles touch, scrolling and snapping still work. Safari 11 to 13.0 rejects overflow: auto hidden: the container is no longer one, the 1,172 px track overflows and nothing scrolls — write overflow-x: auto; overflow-y: hidden. Without snapping (Firefox before 68): free horizontal scrolling with no stop on the tiles.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="sb-horiz">
<div class="sb-horiz-content">
<div class="sb-horiz-track">
<div class="sb-horiz-item">1</div>
<div class="sb-horiz-item">2</div>
<div class="sb-horiz-item">3</div>
<div class="sb-horiz-item">4</div>
<div class="sb-horiz-item">5</div>
<div class="sb-horiz-item">6</div>
</div>
</div>
<span class="sdc-scroll-hint">Scrollez →</span>
</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 |
|---|---|---|
Container cap (CSS — .sb-horiz-content) |
width: 100%; max-width: 900px; margin: 0 auto | The rule: it scrolls as long as the track (1,172 px for six tiles) is longer than the container. Measured on a 1,280 px page: 272 px of travel with the cap, zero without. Fit it to your column, or drop it if the column is already narrower than the track; widen the track (more tiles, or wider ones) before widening the container. |
Background, colour and font (CSS — .sb-horiz) |
#0a0a0f, #fff, sans-serif | The root imposes its background: that is what keeps the hint visible on a white page. For a carousel on a light background, change the background here and recolour the hint (rgba(28,27,25,.55) in the showroom scene); the font only affects the digits and the hint. |
Tile size (CSS — .sb-horiz-item) |
180 × 140 px | Each tile may have its own width (class or style): scroll-snap-align: center centres any width. The narrower the tile relative to the container, the more the neighbours peek. |
scroll-snap-align (CSS — .sb-horiz-item) |
center | start aligns the tile's left edge with the container's: the first tile becomes reachable at 0 and the last one docks to the right. Then add scroll-padding-inline-start: 16px to the container to respect the track's inset. |
scroll-snap-type (CSS — .sb-horiz-content) |
x mandatory | x proximity only settles on a tile when you stop near one, and allows in-between positions; none makes scrolling free. With mandatory, even a scrollLeft written from JavaScript is clamped to a stop (100 → 126 measured). |
Track spacing and inset (CSS — .sb-horiz-track) |
gap: 12px; padding: 20px 16px | The gap is the visible margin between a centred tile and its neighbours; the padding sets where the ends are. For the first and last tiles to centre, raise the horizontal padding to half the container minus half a tile (274 px at 728 px wide). |
“Scrollez →” hint (HTML/CSS — .sdc-scroll-hint) |
.65rem, rgba(255,255,255,.3), 2 s pulse | Anchored to the .sb-horiz root (position: relative): keep it outside the scroll container, otherwise it leaves with the content. Contrast 1.22 to 1.83:1: replace it with a legible instruction, or remove it along with its rule and the sdcHintPulse animation. |
Tile backgrounds (HTML — style attributes) |
6 chained 135° gradients (#6366f1 → … → #06b6d4) | Replace with background: url(…) center/cover or an img in object-fit: cover inside the tile; the gradients are inline, they go away with the HTML. |
FAQ
width: 100% container is 1,264 px wide, all six tiles fit on one line and scrollWidth = clientWidth — zero travel, measured. At 900 px, travel is 272 px and snapping gets its stops back (0, 40, 232, 272). You can raise the cap, or drop it, as long as your track stays longer than your column: with your own tiles (ten 240 px photos, say), the track exceeds 2,500 px and the cap becomes pointless. The catalogue preview is 744 px wide: it scrolls with or without it.overflow: auto hidden, scroll-snap-type: x mandatory) for the first two, but not the same content. Horizontal Snap: slides at min-width: 100% aligned start, one visible at a time, no glimpse of the neighbours. Card Snap Carousel: 200 × 240 px cards with a fixed structure (100 px image, title, text, tag), gap and padding on the container itself, and scroll-behavior: smooth. Sticky Horizontal does not scroll at all: its track slides on a 12 s CSS animation (translateX from 0 to −400 %) inside an overflow: hidden. Horizontal Scroll is the bare version: 1.4 KB of CSS, 180 × 140 px tiles with no inner structure, free width per tile, no scroll-behavior — pick it when the user must keep control of the pace and see what is coming.scrollLeft at 0 — a horizontal container only listens to the horizontal axis (Shift + wheel, side wheel, trackpad, scrollbar, or Tab then Right Arrow in Chrome 130+ and Firefox). For buttons, a scrollBy({ left: 192, behavior: 'smooth' }) is enough (width + gap): with mandatory, Chromium clamps the position to the nearest stop, so even a rough offset lands on a tile (100 → 126 measured). To disable the arrow at the end, compare scrollLeft with scrollWidth − clientWidth in a scroll listener, or scrollend if you do not need Safari before 26.