Scroll-snap galleries: smooth horizontal content without JS

Was this helpful?

You shipped a “simple” horizontal gallery, and now support tickets say it “sticks,” “jumps,” “eats scroll,” or “breaks on iPad.” Product wants it buttery. Accessibility wants keyboard support. Marketing wants it to look like a carousel, but “no JS, please.”

Good news: CSS scroll snapping can carry most of this load—if you design it like you run production systems: predictable, observable, and resilient to bad inputs (like 14MB images and nested overflows). Bad news: it will happily let you build a gallery that works on your laptop and ruins everyone else’s day.

What scroll snap actually is (and isn’t)

Scroll snapping is not a “carousel component.” It’s a browser feature that biases a scroll container to stop at defined snap points. You provide the physics hints; the UA decides how to land. That distinction matters, because you cannot command it like JS. You can only set constraints and let the browser negotiate with input devices, accessibility settings, and user intent.

In practical terms:

  • scroll-snap-type goes on the scrolling container. It declares “snap on the x-axis” and how strictly to do it.
  • scroll-snap-align goes on children. It declares where each item wants to align relative to the container’s snapport.
  • scroll-padding and scroll-margin are the “yes, we have a sticky header” adjustments.
  • scroll-behavior: smooth affects programmatic scrolling (and some UA behaviors), not raw touch physics.

Opinionated baseline: treat scroll-snap as a progressive enhancement. The gallery must be usable as plain horizontal scrolling even if snapping is disabled or ignored.

Two things scroll snap is not:

  • It’s not deterministic. If you test on a trackpad, you’ll see different snap settling than a mouse wheel, and different again on touch. That’s not a bug; that’s the input stream.
  • It’s not a substitute for navigation controls. Some users will want explicit “next/prev” controls. “No JS” doesn’t mean “no controls”; it means you’ll use anchors, focus, and reasonable layout.

One paraphrased idea from engineering culture that applies here (and is the whole reason we bother measuring): “Hope is not a strategy.” — paraphrased idea often attributed to reliability/operations leaders. If your gallery “usually feels fine,” you haven’t tested the failure modes yet.

Design choices that decide whether it feels premium or broken

Mandatory vs proximity: pick your battles

mandatory enforces snapping. It’s great for “one card at a time” experiences. It’s also the mode that makes users feel trapped if your snap points are too dense or your cards are too narrow. proximity is more forgiving: it snaps only when the stop is close enough.

If your users are likely to flick quickly through a feed, prefer proximity. If the gallery is an intentional “stepper” (product images, onboarding), mandatory is reasonable.

Snap alignment: start vs center vs end

scroll-snap-align: start is the most predictable because it matches how people read left-to-right layouts: content begins at a consistent edge. Center alignment looks “designed” but is more sensitive to rounding, container padding, and different viewport widths.

  • Use start when the card has text or any left-aligned UI.
  • Use center when the card is mostly imagery and you’re aiming for a “coverflow-ish” feel (without the 2007 energy).
  • Avoid end unless you have a specific right-edge layout requirement.

scroll-padding and scroll-margin: the sticky header tax

If your layout has a sticky header or any overlay, snapping can land content under it. Use scroll-padding on the container to keep snap positions offset from the edges. Use scroll-margin on children when only some items need different offsets.

Typical pattern:

  • Container has scroll-padding-inline: 16px to keep the first/last card from kissing the viewport edge.
  • Container has scroll-padding-top if this is vertical snapping (not our focus, but same principle).

Spacing: gap vs margin, and why it matters

Snap points are calculated from layout boxes. If you mix gap, margins, and pseudo-elements, you can end up with snap points that don’t match what your designer sees. Keep it boring:

  • Use container gap for spacing between items.
  • Avoid per-item margins unless you need asymmetry.
  • If you need leading/trailing space, use container padding plus scroll-padding.

Nested scrolling: you can have it, but you’ll pay for it

The most common “why does it feel broken” bug is nested scroll containers: a horizontally scrolling gallery inside a vertically scrolling page, plus a card with its own overflow. Trackpads and touch devices then have to guess where the scroll intent goes.

Use:

  • overscroll-behavior: contain to stop scroll chaining where it’s harmful.
  • touch-action: pan-x on the horizontal scroller to reduce vertical confusion.

Scrollbar behavior: stable layout beats pretty

Some platforms overlay scrollbars; others reserve space. When a scrollbar appears/disappears, content can shift, and snap positions can move. That’s how you get “it snapped to the wrong slide” reports that you can’t reproduce.

If you can, use scrollbar-gutter: stable on the scroll container to prevent layout shifts when scrollbars show up. It’s not universal everywhere, but where supported it’s a low-effort win.

Don’t overfit to one device

A mouse wheel sends discrete deltas. A trackpad sends high-resolution continuous deltas. Touch sends momentum with deceleration. Scroll snapping sits downstream of all of that. Testing only with a mouse is how you ship a gallery that feels fine to you and hostile to everyone else.

Accessibility and input devices: touch, trackpad, keyboard

Keyboard support without JS: focus is your friend

Without JavaScript, you won’t “listen to arrow keys” and update scroll position. But you can still provide a reasonable keyboard experience:

  • Make items focusable (tabindex="0") or include focusable content inside (links/buttons).
  • Ensure focused items are visible. Browsers will typically scroll focused elements into view—often respecting snap.
  • Use :focus-visible to show a clear focus ring.

If you want explicit next/prev without JS, you can do it with anchor links to IDs on slides. It’s old-school, but it works and it’s accessible.

cr0x@server:~$ cat anchor-nav.html
<style>
  .gallery { overflow-x: auto; scroll-snap-type: x mandatory; display: grid; grid-auto-flow: column; grid-auto-columns: 85%; gap: 16px; padding: 16px; }
  .slide { scroll-snap-align: start; border: 1px solid #e6e6e6; border-radius: 12px; min-height: 200px; }
  .nav a { margin-right: 10px; }
  .gallery { scroll-behavior: smooth; }
  @media (prefers-reduced-motion: reduce) { .gallery { scroll-behavior: auto; } }
</style>

<div class="nav" aria-label="Gallery navigation">
  <a href="#s1">1</a>
  <a href="#s2">2</a>
  <a href="#s3">3</a>
</div>

<div class="gallery">
  <section id="s1" class="slide" tabindex="-1">Slide 1</section>
  <section id="s2" class="slide" tabindex="-1">Slide 2</section>
  <section id="s3" class="slide" tabindex="-1">Slide 3</section>
</div>

tabindex="-1" on slides allows focus when navigated to by anchor without adding them to the tab order. That keeps the page’s tabbing sane.

Reduced motion is not optional

If you add scroll-behavior: smooth, you must respect prefers-reduced-motion. Smooth scrolling can make some users nauseous, and it can make debugging worse because every interaction becomes animated. Make smooth scrolling conditional and move on with your life.

Screen readers and semantics

Scroll-snap galleries are still just scroll containers. Don’t try to role-play a “carousel widget” unless you implement the full interactive semantics (which usually means JS). Stick to good HTML:

  • Use <section>, <article>, <figure> with <figcaption>, and meaningful headings.
  • Label the region with aria-label if it’s not already described by adjacent text.
  • Don’t trap focus inside the gallery.

Touch-action: a sharp tool

touch-action: pan-x can improve horizontal intent on touch devices, but be careful: if your gallery sits inside a vertically scrolling page, you still want users to be able to scroll vertically when their finger isn’t perfectly horizontal. Test it. If the gallery is tall and content-dense, you may prefer leaving touch-action alone and relying on good spacing and overscroll behavior.

Performance: the real bottleneck is rarely “scroll snap”

When someone says “scroll snap is janky,” they usually mean “scrolling is janky while snap is enabled.” That’s an important difference. Snapping can expose performance problems that were already present: oversized images, layout thrash, expensive paint, and nested compositing.

What makes snap feel bad

  • Layout shifts during scroll: images without dimensions, fonts swapping in, dynamic content loading into cards.
  • Heavy paint: big box-shadows, filters, backdrop-filter, and large translucent layers.
  • Main-thread contention: scroll-linked effects, expensive CSS selectors, too many sticky elements.
  • Memory pressure: many high-resolution images decoded at once; browser starts evicting surfaces.

Stabilize layout first: lock image aspect ratios

If card height changes mid-scroll, the scroll container’s geometry changes, and snap points can feel like they “move.” The fix is boring and effective: set image width/height attributes (or CSS aspect-ratio) so the browser can reserve space before decode.

Containment and content-visibility: use, but verify

content-visibility: auto and contain can improve performance for large pages by skipping offscreen rendering. But in scroll-snap galleries, the “offscreen” region is often only one card away—and snapping can force near-instant reveal. Overusing these can make the snap landing feel like a blank flash.

Use them when you have lots of heavy slides. Then validate on low-end devices and Safari. If you see blanking, dial it back or preload one slide ahead.

Compositing: don’t accidentally create 40 layers

Randomly sprinkling will-change: transform is the frontend equivalent of turning on every caching layer because “caches are fast.” It’s how you burn memory and get weird glitches.

Short joke #2: will-change is like printing “URGENT” on every email—eventually nothing is.

CSS scroll snapping and smooth scrolling

scroll-behavior: smooth can mask issues by making motion appear “designed,” but it can also amplify the sense of lag because animations will stutter under load. Don’t use smooth scrolling as a performance band-aid. Fix the root cause: layout and paint.

Facts and short history worth knowing

  1. Early “snap points” ideas existed in UI toolkits long before CSS—think paginated scroll views in native mobile frameworks.
  2. CSS Scroll Snap started as a W3C effort to formalize “paging” behavior for touch-first UIs, especially as mobile browsing exploded.
  3. The property names changed over time; older drafts used different naming, which is why you’ll still see stale blog snippets in the wild.
  4. scroll-snap-stop exists because users complained about skipping past items when flicking with momentum; it’s a control knob for “must stop here.”
  5. Momentum scrolling itself is not a CSS feature—it’s platform behavior. Snap algorithms must coexist with OS-level physics.
  6. Browser engines treat scrolling as a high-priority pipeline; modern implementations try hard to keep scrolling off the main thread when possible.
  7. Scroll snapping interacts with accessibility features like reduced motion; users can override your intent, and they should be able to.
  8. RTL (right-to-left) layouts complicate horizontal snapping because “start” and “end” flip; good testing includes RTL even if your product is mostly English.
  9. Scrollbar rendering varies by OS and settings; snap points that assume a fixed gutter can drift when scrollbars are non-overlay.

Three corporate mini-stories from the trenches

Incident: the wrong assumption (“snap points are just the card edges”)

At a mid-sized company, a product team shipped a scroll-snap gallery for a pricing page. Looked great in a desktop Chrome screenshot. On iOS Safari, users reported that the gallery “refused to settle” and sometimes snapped to what looked like half a card.

The wrong assumption was subtle: the engineer assumed snap positions would align to the visual left edge of the card. But the design used negative margins on cards to create an “overhang” effect, plus a pseudo-element for a gradient fade. The layout box edge didn’t match the visual edge.

Support reproduced it on iPhone quickly. Engineering couldn’t reproduce on MacBooks with trackpads. That mismatch delayed the fix because everyone argued about whether it was “real.” It was real. It was input-device dependent and layout-box dependent.

The fix was boring: remove the negative margins, move the decorative overhang into padding and background layers inside the card, and use container gap. Snap positions became stable because they matched the boxes the browser uses to compute snap points.

Postmortem takeaway: when you build scroll snap, your layout boxes are the API. If your visuals don’t match the boxes, the browser will faithfully snap to the boxes, not your intentions.

Optimization that backfired (“we’ll lazy-render everything”)

Another team had a “feature tiles” scroller on the homepage. Performance was fine on laptops, but Android devices stuttered hard. An engineer added content-visibility: auto on each card and aggressively lazy-loaded images to reduce initial work.

Metrics improved for initial render. Everyone celebrated. Then the bug reports came in: users would flick the gallery and see blank tiles for a beat, sometimes long enough to look broken. The snapping made it worse because it forced the viewport to “land” on a tile that hadn’t been rendered yet.

The root problem wasn’t that lazy rendering is bad. It’s that scroll-snap makes “next item” a guaranteed near-term target. If you skip rendering too aggressively, you create a visible gap right at the moment of interaction, which users interpret as lag.

The recovery plan was to keep content-visibility, but limit it: render the current tile plus one ahead/behind by ensuring those elements are “visible enough” (e.g., with a less aggressive threshold or by not applying it to the nearest neighbors). Also, predeclare image sizes and use responsive image sources to reduce decode spikes.

Lesson: optimize for interaction, not for Lighthouse screenshots. A gallery is an interaction surface. Treat it like a hot path, because it is.

Boring but correct practice that saved the day (“feature flag + quick rollback”)

A retailer replaced a JS carousel with CSS scroll snap to reduce bundle size. The change was behind a feature flag. No fanfare. Just a controlled rollout with a kill switch.

During ramp-up, customer success reported that some users on older Safari saw weird “rubber band” behavior: the gallery would overscroll and bounce, then snap to the wrong item. It wasn’t universal, and repro required specific OS settings.

Instead of spending a weekend arguing about whether Safari was “wrong,” the on-call SRE did what on-call should do: reduced blast radius. They flipped the flag off for affected user agents while engineering investigated. No drama. No emergency patch. No midnight deploy.

Engineering then built a small UA-based mitigation: for the problematic Safari versions, they switched from mandatory to proximity and removed smooth scrolling. They also added overscroll-behavior and simplified nested overflow. The bug rate dropped to background noise.

The practice that saved the day wasn’t clever CSS. It was operational discipline: gradual rollout, measurable error reports, and a rollback path that didn’t require heroics.

Fast diagnosis playbook

You have a scroll-snap gallery. Users say it’s janky, jumps to wrong items, or doesn’t snap. You need a fast path to the bottleneck without turning debugging into a lifestyle.

First: confirm the container and snap points are real

  1. Verify the scroll container is the element you think it is (overflow-x: auto on the right node).
  2. Verify children are direct participants with scroll-snap-align (not applied to an inner wrapper you don’t actually scroll to).
  3. Check for nested overflow containers inside slides that might steal scroll or cause scroll chaining.

Second: isolate layout shifts

  1. Disable image loading (or replace with fixed-size placeholders) and see if the problem disappears.
  2. Check if fonts are swapping in after first paint.
  3. Look for dynamic content injection (ads, personalization, “recommended” modules) changing card size.

Third: profile scroll performance like an adult

  1. Record a performance profile during scrolling and snap settle.
  2. Look for long main-thread tasks during scroll.
  3. Look for heavy paint and compositing (big shadows, filters, fixed-position elements).

Fourth: test across input methods and settings

  1. Trackpad vs mouse wheel vs touch (real device if possible).
  2. Reduced motion enabled.
  3. RTL if your product supports it (or will).

Decision rule: if snap is wrong, fix layout and snap geometry. If snap is right but interaction is unpleasant, fix performance and input handling (overscroll/touch-action), or relax from mandatory to proximity.

Practical tasks: commands, outputs, and decisions

These are the kinds of checks I expect in a real incident channel: quick, deterministic, and tied to a decision. Commands are runnable on a typical Linux dev box. When the check is “frontend,” we still use system tools because production debugging is cross-disciplinary.

Task 1: Confirm the gallery CSS is actually deployed

cr0x@server:~$ curl -sS -D- https://example.test/gallery | head
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=60
etag: "a1b2c3"
...

What the output means: You’re getting a 200 with HTML. Basic connectivity is fine.

Decision: If you see a redirect loop or a 500, stop blaming CSS and fix delivery first.

Task 2: Verify the CSS contains scroll-snap rules

cr0x@server:~$ curl -sS https://example.test/assets/app.css | grep -n "scroll-snap" | head
1842:.gallery{scroll-snap-type:x mandatory;scroll-padding-inline:16px}
1843:.card{scroll-snap-align:start}

What the output means: The CSS bundle includes the properties. If grep returns nothing, your build pipeline likely tree-shook the styles or you’re hitting the wrong asset.

Decision: Missing rules means deployment/build issue, not a browser bug. Fix bundling or selectors.

Task 3: Check whether compression is working (large CSS/HTML can delay interaction)

cr0x@server:~$ curl -sS -I https://example.test/assets/app.css | egrep -i "content-encoding|content-length|cache-control"
cache-control: public, max-age=31536000, immutable
content-encoding: br
content-length: 41231

What the output means: Brotli is on, size is manageable, caching is strong.

Decision: If there’s no compression and content-length is huge, fix that before micro-optimizing scroll behavior.

Task 4: Measure image payload size (oversized images are the #1 scroll-jank culprit)

cr0x@server:~$ curl -sS -I https://example.test/media/slide-1.jpg | egrep -i "content-type|content-length|cache-control"
content-type: image/jpeg
content-length: 8421932
cache-control: public, max-age=31536000

What the output means: That’s ~8MB for one image. On mobile, this is a problem even with perfect CSS.

Decision: Add responsive images, modern formats, and set dimensions. Reduce bytes before debating snap alignment.

Task 5: Confirm server supports range requests (helps with media handling and some browser strategies)

cr0x@server:~$ curl -sS -I https://example.test/media/slide-1.jpg | egrep -i "accept-ranges"
accept-ranges: bytes

What the output means: Range requests are enabled.

Decision: If absent, large media delivery can be less efficient. Investigate CDN/origin config.

Task 6: Identify long tasks during scroll on a test box (system-level CPU pressure)

cr0x@server:~$ top -b -n 1 | head -n 12
top - 10:21:14 up 12 days,  4:02,  1 user,  load average: 2.11, 1.88, 1.74
Tasks: 238 total,   1 running, 237 sleeping,   0 stopped,   0 zombie
%Cpu(s): 18.2 us,  3.3 sy,  0.0 ni, 77.8 id,  0.0 wa,  0.0 hi,  0.7 si,  0.0 st
MiB Mem :  15948.5 total,   2142.9 free,   6120.3 used,   7685.3 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   9182.2 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 4121 cr0x      20   0 3241560 482912 156324 S  48.0   3.0  10:12.33 chrome

What the output means: Chrome is using a lot of CPU during your interaction test.

Decision: If CPU pegs on scroll, expect jank. Move to browser profiling and reduce paint/layout work.

Task 7: Check if the system is swapping (swap makes everything feel haunted)

cr0x@server:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:            15Gi       5.9Gi       2.1Gi       268Mi       7.5Gi       8.9Gi
Swap:          2.0Gi          0B       2.0Gi

What the output means: Swap isn’t used. Good baseline for reliable perf testing.

Decision: If swap is heavily used, your “scroll snap jank” test is invalid. Fix the machine state first.

Task 8: Detect layout shift signals in field logs (CLS correlation)

cr0x@server:~$ journalctl -u webapp -n 30 | grep -i "CLS" | tail
Dec 29 10:19:12 web01 webapp[2381]: rum metric CLS=0.21 route=/pricing device=mobile
Dec 29 10:19:48 web01 webapp[2381]: rum metric CLS=0.19 route=/pricing device=mobile

What the output means: Field CLS is elevated on the page that hosts the gallery.

Decision: Prioritize layout stability (image dimensions, font loading) before tuning snap behavior. Snap can’t fix shifting geometry.

Task 9: Verify font files aren’t huge or slow (font swaps can shift card widths)

cr0x@server:~$ curl -sS -I https://example.test/assets/fonts/Inter-var.woff2 | egrep -i "content-length|cache-control|content-type"
content-type: font/woff2
content-length: 986432
cache-control: public, max-age=31536000, immutable

What the output means: Nearly 1MB font. Not automatically bad, but it’s a suspect for late swaps.

Decision: Consider subsetting, or ensure font-display strategy doesn’t cause visible layout shifts in cards.

Task 10: Check HTTP caching correctness (avoid re-downloading gallery assets)

cr0x@server:~$ curl -sS -I https://example.test/assets/app.css | egrep -i "etag|last-modified|cache-control"
cache-control: public, max-age=31536000, immutable
etag: "d34db33f"

What the output means: Strong caching with immutable assets.

Decision: If caching is weak, users re-download assets and interactions start late. Fix caching before arguing about CSS micro-details.

Task 11: Confirm your page isn’t accidentally disabling overflow scrolling

cr0x@server:~$ rg -n "overflow-x:\s*hidden|overflow:\s*hidden" -S ./src | head
src/styles/layout.css:44:body { overflow-x: hidden; }
src/components/Gallery.css:3:.gallery { overflow-x: auto; }

What the output means: The body has overflow-x: hidden. That can be fine, but it’s a common cause of “can’t scroll the gallery” bugs when combined with other layout constraints.

Decision: If the gallery doesn’t scroll on some devices, audit global overflow rules and container sizing.

Task 12: Check for accidental scroll container resizing (viewport units, dynamic toolbars)

cr0x@server:~$ rg -n "100vw|100vh|dvh|svh|lvh" ./src/styles | head
src/styles/gallery.css:12:.gallery { width: 100vw; }
src/styles/page.css:8:.page { min-height: 100vh; }

What the output means: width: 100vw can include the scrollbar width on some platforms, causing subtle horizontal overflow and snap drift.

Decision: Prefer width: 100% for containers, and manage padding explicitly. If you need viewport sizing, test scrollbar and mobile dynamic toolbar behavior.

Task 13: Validate you didn’t introduce huge paint costs (box-shadows everywhere)

cr0x@server:~$ rg -n "box-shadow:|filter:|backdrop-filter:" ./src/styles | head -n 12
src/styles/cards.css:18:.card { box-shadow: 0 24px 80px rgba(0,0,0,0.22); }
src/styles/hero.css:9:.hero { backdrop-filter: blur(18px); }

What the output means: Large, soft shadows and backdrop filters are paint-heavy, especially during scroll.

Decision: Reduce shadow blur/spread, drop backdrop-filter in scroll contexts, or confine effects to non-scrolling layers.

Task 14: Confirm build didn’t strip prefixed or fallback properties (Safari quirks)

cr0x@server:~$ node -p "process.versions.node"
22.11.0

What the output means: You have a modern Node environment. If your CSS pipeline is modern too, it might be relying on features that your target browsers don’t fully support without fallbacks.

Decision: Ensure your CSS is tested against your supported browser matrix. If Safari is in scope, validate behavior on real Safari, not just “WebKit-ish” assumptions.

Task 15: Audit number of gallery items (too many slides = memory pressure)

cr0x@server:~$ python3 - <<'PY'
from bs4 import BeautifulSoup
html = open("gallery.html","r",encoding="utf-8").read()
s = BeautifulSoup(html,"html.parser")
print("cards:", len(s.select(".card")))
PY
cards: 4

What the output means: The sample is small. Real pages often have 30+ items.

Decision: If you have lots of slides with heavy content, consider pagination, fewer items, or rendering strategy—but test for blanking with snap.

Common mistakes: symptoms → root cause → fix

1) “It doesn’t snap at all”

  • Symptoms: Horizontal scrolling works, but it never lands cleanly on items.
  • Root cause: scroll-snap-type isn’t on the actual scroll container, or the container doesn’t scroll (no overflow).
  • Fix: Put overflow-x: auto and scroll-snap-type: x ... on the same element. Ensure its width is constrained so overflow exists.

2) “It snaps to weird half positions”

  • Symptoms: Items align inconsistently; sometimes you see half of the next card.
  • Root cause: Visual layout doesn’t match box geometry (negative margins, transforms, pseudo-elements affecting perceived edges).
  • Fix: Remove negative margins from snap items; use gap and padding. Keep snap items’ boxes aligned to what users see.

3) “Scrolling the page gets stuck in the gallery”

  • Symptoms: On mobile, vertical scrolling becomes hard when the finger passes over the gallery.
  • Root cause: Horizontal scroll container captures touch intent; nested scroll chaining; aggressive touch-action.
  • Fix: Use overscroll-behavior-x: contain and consider removing or relaxing touch-action. Make the gallery shorter so it’s less likely to intercept vertical scroll.

4) “The snap lands under the header”

  • Symptoms: The beginning of a card is hidden behind sticky UI.
  • Root cause: No scroll-padding or scroll-margin to account for overlays.
  • Fix: Set scroll-padding-inline or scroll-padding-top on the scroll container depending on axis.

5) “It’s buttery on desktop, awful on phones”

  • Symptoms: Mobile stutter, blank flashes, delayed image appearance when snapping.
  • Root cause: Overweight images and decode time; lazy rendering too aggressive; heavy paint effects.
  • Fix: Use responsive images, set dimensions/aspect-ratio, simplify shadows/filters, and avoid hiding rendering for near-offscreen items.

6) “It jumps when the scrollbar appears”

  • Symptoms: On desktop, the gallery shifts slightly and then snap alignment feels off.
  • Root cause: Scrollbar gutter changes layout, often due to OS settings or hover-scrollbars.
  • Fix: Use scrollbar-gutter: stable where supported; otherwise ensure container sizing doesn’t depend on 100vw.

7) “Safari ignores my nice behavior”

  • Symptoms: Different snap settling than Chromium/Firefox; weird rubber-banding.
  • Root cause: Engine differences plus momentum physics; also often nested overflow and transforms.
  • Fix: Simplify: fewer nested scroll regions, avoid transforms on scroll container parents, consider switching to proximity, remove smooth scrolling for affected builds.

8) “Keyboard users can’t reach content”

  • Symptoms: Tab doesn’t enter slides, or focus ring disappears offscreen.
  • Root cause: No focusable elements; focus styles removed; overflow clipping without scroll-to-focus behavior.
  • Fix: Ensure focusable content exists; add :focus-visible styling; avoid outline: none without replacement.

Checklists / step-by-step plan

Step-by-step: build a snap gallery that doesn’t embarrass you later

  1. Pick the layout model. Use CSS Grid columns for cards. Avoid tricky floats, negative margins, and transforms on the scroll container.
  2. Create a real scroll container. overflow-x: auto, width constrained by parent, no global overflow rules fighting it.
  3. Define snap behavior. Start with scroll-snap-type: x proximity unless you have a strong reason for mandatory.
  4. Set item alignment. Default to scroll-snap-align: start.
  5. Add predictable spacing. Use gap and container padding; set scroll-padding-inline to match.
  6. Make it keyboard-usable. Ensure focusable elements exist; add :focus-visible.
  7. Respect reduced motion. Only enable smooth behavior when motion preferences allow it.
  8. Stabilize media layout. Declare image dimensions or aspect ratios; avoid late-loading content that changes card size.
  9. Watch nested overflow. Don’t put scrollable regions inside scrollable slides unless you really must.
  10. Test across inputs. Mouse, trackpad, touch device, and at least one Safari.
  11. Ship behind a flag if high-risk. Especially for high-traffic marketing pages where “small UX regressions” become real money.
  12. Measure and iterate. Monitor CLS, interaction latency signals, and user feedback correlated to device/browser.

Pre-ship checklist (fast)

Geometry: container scrolls; children have snap align; no negative margins on snap items.
UX: scrollbar visible or alternative exists; focus ring visible; anchors work if provided.
Perf: images sized and compressed; no giant shadows/filters; no blanking on quick flicks.
Compatibility: tested on Safari and at least one Android device; reduced motion verified.

FAQ

1) Should I use mandatory or proximity?

Default to proximity unless the gallery is explicitly step-based (product photos, onboarding). mandatory increases “stuck” complaints if your snap points are dense or your cards are narrow.

2) Why does it behave differently on a trackpad than a mouse wheel?

Because the input stream is different: trackpads send continuous, high-resolution deltas; mouse wheels send coarse ticks. Snapping happens after the scroll settles, and “settle” timing differs by device.

3) Can I build a full carousel (dots, next/prev, autoplay) without JS?

You can build navigation with anchors and style it nicely. Autoplay without JS is a bad idea for accessibility and user control anyway. For “dots,” use links to slide IDs and keep it simple.

4) Does scroll-behavior: smooth make touch scrolling smooth?

Not really. It mainly affects programmatic scroll and certain UA-driven scroll actions. Touch momentum is platform physics. Don’t rely on smooth scroll to fix jank.

5) My slides have padding—why are snap positions off by a few pixels?

Usually rounding and box sizing. Prefer aligning to a stable edge (start), match container padding with scroll-padding, and avoid fractional widths when possible (like 33.333% plus large gaps).

6) How do I stop the page from scrolling when the user is interacting with the gallery?

Use overscroll-behavior-x: contain on the gallery. Consider touch-action: pan-x carefully; it can improve intent but can also make vertical scroll harder over the gallery.

7) Why do some items “skip” when I flick fast?

Momentum can carry the scroll past multiple snap points. If you need every item to be a hard stop, try scroll-snap-stop: always on items—but test it; it can feel restrictive.

8) Should I hide the scrollbar for a cleaner look?

Only if you replace it with something equally clear and accessible. Otherwise you’re removing an affordance and replacing it with vibes. If you must hide it, ensure keyboard and touch usability are excellent.

9) Can scroll snap cause layout shift (CLS)?

Scroll snap doesn’t create CLS by itself. But it makes layout shifts more noticeable because the user expects a stable landing. CLS usually comes from images without reserved space, late font swaps, or dynamic content injection.

10) What’s the simplest way to make it responsive?

Use grid auto-columns in percentages and adjust at breakpoints. Example: 85% on small screens (one card mostly visible), 45% on wide screens (two-ish cards visible). Then tune gap and scroll padding.

Conclusion: what to do next

If you want smooth horizontal galleries without JavaScript, scroll snap is the right primitive. But it’s not magic. Treat it like any other production feature: keep the geometry honest, keep the layout stable, and measure the bottlenecks instead of guessing.

Next steps you can do this week:

  1. Refactor your gallery to a single, obvious scroll container with predictable child boxes (grid + gap + padding).
  2. Switch to proximity unless product truly needs strict snapping, and add scroll-padding-inline to match design spacing.
  3. Fix media: declare dimensions/aspect ratios and reduce image bytes until mobile devices stop sweating.
  4. Run the fast diagnosis playbook on Safari and one real phone before you argue about “browser quirks.”
  5. If the page is high-impact, ship behind a flag and be ready to roll back. That’s not pessimism; it’s how you keep weekends intact.

Built with the assumption that production systems are real, browsers are opinionated, and users will always find the one device you didn’t test.

← Previous
Gmail/Outlook delivery issues: the checks that matter in 2025
Next →
Why printer drivers got huge: the bloat nobody asked for

Leave a comment