Fix: CSS Scroll Behavior Not Working — smooth scroll, scroll-snap, or scroll-margin Ignored
Part of: React & Frontend Errors
Quick Answer
How to fix CSS scroll issues — scroll-behavior: smooth not working, scroll-snap alignment problems, overflow container conflicts, scroll-margin for fixed headers, and browser compatibility.
The Problem
scroll-behavior: smooth is set but the page still jumps instantly:
html {
scroll-behavior: smooth;
}Or scroll-snap-type is set on the container but items don’t snap:
.carousel {
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
.carousel-item {
scroll-snap-align: start; /* Items don't snap */
}Or anchor links scroll to the right position but content is hidden under a fixed header:
<a href="#section-2">Go to Section 2</a>
<!-- Section title ends up behind the navbar -->
<h2 id="section-2">Section 2</h2>Or programmatic scrolling with scrollTo ignores smooth behavior:
window.scrollTo({ top: 500, behavior: 'smooth' });
// Jumps instantly despite the CSS settingWhy This Happens
CSS scroll behavior has several environment-dependent rules:
prefers-reduced-motion— browsers and operating systems can disable smooth scrolling when users have enabled “Reduce Motion” in accessibility settings. If a user has this on,scroll-behavior: smoothis intentionally overridden.- The scrolling container must have
overflow—scroll-snap-typeonly works on the element that is actually scrolling. If the scroll happens on<html>or a parent, settingscroll-snap-typeon a child has no effect. scroll-snap-alignrequires defined dimensions — snap items must have explicit width/height (orflex/gridsizing) to snap correctly. Percentage sizes without a defined parent dimension fail silently.- Fixed/sticky headers obscure anchor targets — when you navigate to
#section, the browser scrolls theidelement to the top of the viewport, placing it directly behind a fixed header. - JavaScript
scrollTovs CSSscroll-behavior—scroll-behaviorin CSS affects scrolling triggered by anchor navigation andscrollTo()withbehavior: 'smooth'. Without passingbehavior: 'smooth'toscrollTo(), it jumps regardless of CSS.
The third source of “scroll behavior not working” reports is browser version. These properties shipped in different browsers at very different times, and Safari/iOS in particular only became reliable around 2022. If you’re testing on the latest Chrome but your users are on a two-year-old iPhone, your “Fix” may not be a fix at all.
Scroll Behavior Specs and Browser Support — What Shipped When
- CSS Scroll Snap Module Level 1 was a draft from late 2014, but the modern syntax (the one this article uses) only stabilized in 2017. Older articles describe
scroll-snap-points-x/scroll-snap-coordinate, which were dropped and replaced byscroll-snap-type+scroll-snap-align. If you find old code using the deprecated syntax, treat it as broken — no current browser still implements it. - Firefox 36 (Feb 2015) shipped early
scroll-behavior: smoothsupport behind a prefix and the legacy snap properties. It moved to the unprefixed modern syntax in Firefox 68 (July 2019). - Chrome 61 (Sept 2017) shipped unprefixed
scroll-behavior: smoothand the modernscroll-snap-type/scroll-snap-alignsyntax. This is the baseline most “modern” tutorials assume. - Edge (Chromium-based, Jan 2020) inherited everything from Chrome. Pre-Chromium Edge had partial, often broken support — if you’re maintaining IE11 / pre-Chromium Edge code, polyfill or fall back to JS.
- Safari 11 (Sept 2017) added scroll snap with the
-webkit-prefix; the unprefixed syntax came later. Mid-2018-2020 Safari versions are riddled with snap bugs — flex children especially. - Safari 14 (Sept 2020) improved snap behaviour and finally accepted unprefixed
scroll-behavior, butscroll-behavior: smoothwas still ignored — Safari treated it asauto. This is the source of most “smooth scroll works on Chrome, jumps on Mac” reports from 2020-2021. - iOS 15.4 Safari (March 2022) was the watershed:
scroll-behavior: smoothfinally produced smooth animation on iOS. Before 15.4, JSwindow.scrollTo({ behavior: 'smooth' })and the CSS property both no-op’d on iOS. If your analytics shows older-iOS users, assume CSS smooth scroll silently degrades for them. - Chrome 129 (Sept 2024) added the
scrollsnapchangeandscrollsnapchangingevents. Before this, you neededIntersectionObserverto detect which snap target was active. - Safari 18.2 (Dec 2024) added matching
scrollsnapchange/scrollsnapchangingevent support — these are now usable across Chromium and WebKit, but Firefox is still catching up. scroll-marginandscroll-padding(the fix for fixed-header anchor targets) landed in Chrome 69 (Sept 2018), Firefox 68 (July 2019), and Safari 14.1 (April 2021). Pre-2021 iOS does not honourscroll-margin-top— fall back to JS offset math.
Pin your support floor (typically iOS 15.4 / Safari 14 / Chrome 100 / Firefox 100 for sites updated in the last year) and check the matrix above before declaring a fix.
Fix 1: Ensure scroll-behavior Applies to the Right Element
scroll-behavior: smooth must be on the scrolling element — usually html for page-level scrolling:
/* Most common setup — applies to anchor links and history navigation */
html {
scroll-behavior: smooth;
}
/* If your page scrolls inside a container, not <html> */
.scroll-container {
height: 100vh;
overflow-y: auto;
scroll-behavior: smooth;
}Respect user preferences:
/* Always include this to avoid vestibular disorders for users with prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Or use the modern approach: opt-in rather than opt-out */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}JavaScript scrollTo — always pass behavior: 'smooth' explicitly:
// WRONG — CSS scroll-behavior doesn't affect this
window.scrollTo(0, 500); // Always instant
// CORRECT — pass behavior explicitly
window.scrollTo({ top: 500, behavior: 'smooth' });
// Scroll an element into view
document.getElementById('section-2').scrollIntoView({ behavior: 'smooth' });
// Scroll a container
document.querySelector('.scroll-container').scrollTo({
top: 300,
behavior: 'smooth'
});Fix 2: Fix scroll-snap Not Snapping
For scroll snap to work, the snap container and snap items must be set up correctly:
/* WRONG — scroll-snap-type on non-scrolling element */
.wrapper {
scroll-snap-type: x mandatory; /* .wrapper doesn't scroll */
}
.carousel {
overflow-x: scroll; /* This is the scrolling element */
}
/* CORRECT — scroll-snap-type on the element that scrolls */
.carousel {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
/* Remove scrollbar bounce/rubber-band that interferes on iOS */
-webkit-overflow-scrolling: touch;
}
.carousel-item {
flex: 0 0 100%; /* Full width, no shrinking */
scroll-snap-align: start;
}mandatory vs proximity:
/* mandatory — always snaps to an item, even mid-scroll */
scroll-snap-type: x mandatory;
/* proximity — only snaps if the scroll position is close to a snap point */
scroll-snap-type: x proximity;
/* Both axes */
scroll-snap-type: both mandatory;Vertical scroll snap for full-page sections:
html, body {
height: 100%;
margin: 0;
}
.sections-container {
height: 100vh;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.section {
height: 100vh;
scroll-snap-align: start;
scroll-snap-stop: always; /* Forces stop at each section, prevents skipping */
}scroll-snap-stop: always prevents users from scrolling past a snap point in one swipe gesture — useful for carousels where skipping items is undesirable.
Fix 3: Fix Anchor Links Hidden Under Fixed Headers
When navigating to an anchor, the target scrolls to the top of the viewport — under a fixed header. Fix this with scroll-margin-top:
/* Add scroll margin equal to header height */
:target {
scroll-margin-top: 80px; /* Match your header height */
}
/* Or apply to all headings that are anchor targets */
h1, h2, h3, h4, h5, h6 {
scroll-margin-top: 80px;
}
/* Use CSS custom property for easy maintenance */
:root {
--header-height: 64px;
}
[id] { /* All elements with an id */
scroll-margin-top: calc(var(--header-height) + 16px); /* + extra breathing room */
}Dynamic header height with JavaScript:
// Measure the header and update the CSS variable
function updateScrollMargin() {
const header = document.querySelector('header');
const height = header.getBoundingClientRect().height;
document.documentElement.style.setProperty('--header-height', `${height}px`);
}
updateScrollMargin();
window.addEventListener('resize', updateScrollMargin);For SPA anchor navigation where the URL changes but the element is already in the DOM:
// Smooth scroll to anchor, accounting for header offset
function scrollToAnchor(id) {
const el = document.getElementById(id);
if (!el) return;
const headerHeight = document.querySelector('header').offsetHeight;
const elementTop = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementTop - headerHeight - 16, // 16px extra breathing room
behavior: 'smooth'
});
}If you must support pre-iOS-15.4 Safari, this JS fallback is necessary — scroll-margin-top and behavior: 'smooth' both no-op there.
Fix 4: Fix scroll-snap on Mobile and iOS
Mobile browsers have additional quirks with scroll snap:
/* iOS Safari: must explicitly set overflow on touch devices */
.carousel {
overflow-x: scroll;
-webkit-overflow-scrolling: touch; /* Enable momentum scrolling on iOS */
scroll-snap-type: x mandatory;
/* Hide scrollbar on WebKit without affecting scrollability */
scrollbar-width: none; /* Firefox */
}
.carousel::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.carousel-item {
scroll-snap-align: center;
/* Items must have explicit width — percentage won't work without a flex parent */
width: 100%;
flex-shrink: 0;
}Padding inside snap containers:
/* scroll-padding-* on the container offsets where snapping lands */
.carousel {
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-padding-left: 24px; /* First item snaps 24px from the left edge */
}
/* Useful for carousels that show a peek of the next item */
.carousel {
padding: 0 48px; /* Show 48px of adjacent items */
scroll-padding: 0 48px;
}Note:
-webkit-overflow-scrolling: touchis no longer required on iOS 13+ — momentum scrolling is the default. Keep it in legacy stylesheets only if you support older iOS.
Fix 5: Programmatic Scroll Management in SPAs
Single-page apps with client-side routing need explicit scroll management:
// React Router v6 — ScrollRestoration component
import { ScrollRestoration } from 'react-router-dom';
function App() {
return (
<>
<ScrollRestoration />
<Outlet />
</>
);
}
// Vue Router — scroll behavior
const router = createRouter({
history: createWebHistory(),
routes: [...],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
// Restore scroll position on browser back/forward
return savedPosition;
}
if (to.hash) {
// Smooth scroll to anchor
return {
el: to.hash,
behavior: 'smooth',
top: 80 // Account for fixed header
};
}
// Scroll to top on new navigation
return { top: 0, behavior: 'smooth' };
}
});
// Next.js App Router — scroll to top on route change
// Handled automatically; use scroll={false} on <Link> to disable
<Link href="/about" scroll={false}>About</Link>Fix 6: Advanced Scroll Snapping Patterns
Multi-item carousel (showing multiple items at once):
.carousel {
display: grid;
grid-auto-flow: column;
grid-auto-columns: calc(33.33% - 16px); /* 3 items visible */
gap: 24px;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-padding: 0 24px;
padding: 0 24px;
}
.carousel-item {
scroll-snap-align: start;
}Vertical scroll snap with variable-height sections:
/* Snap to sections even if they're taller than the viewport */
.page {
overflow-y: scroll;
scroll-snap-type: y proximity; /* proximity works better for tall sections */
}
.section {
/* Don't set min-height: 100vh — let content determine height */
scroll-snap-align: start;
scroll-margin-top: 64px;
}Detect snap position changes with JavaScript (Chrome 129+, Safari 18.2+):
const carousel = document.querySelector('.carousel');
carousel.addEventListener('scrollsnapchanging', (e) => {
// Fires while user is dragging — Chrome 129+ / Safari 18.2+
console.log('Snapping to:', e.snapTargetInline);
});
carousel.addEventListener('scrollsnapchange', (e) => {
// Fires when snap completes
const activeIndex = [...carousel.children].indexOf(e.snapTargetInline);
updateDots(activeIndex);
});Note: Firefox does not yet ship
scrollsnapchange/scrollsnapchanging(as of mid-2025). Polyfill withIntersectionObserverwhen supporting Firefox is non-negotiable.
Still Not Working?
scroll-behavior doesn’t work in some browsers — scroll-behavior is not supported in iOS Safari before version 15.4. For broader compatibility, use a JavaScript polyfill or pass behavior: 'smooth' explicitly in your scrollTo() calls.
Snap container must have a defined size — if overflow: scroll is on the snap container but the container has no explicit height (for vertical snap) or width (for horizontal snap), the container might not be scrollable at all. Verify with DevTools that the container is both scrollable and the correct element is triggering the scroll.
overscroll-behavior prevents scroll chaining — if scroll is stopping at the wrong container boundary (e.g., a modal capturing the page scroll), add overscroll-behavior: contain to the inner scroller:
.modal-body {
overflow-y: auto;
overscroll-behavior: contain; /* Don't propagate scroll to the page behind */
}Safari ignores scroll-snap-align on flex children — ensure each snap child has an explicit dimension. In flex containers, set flex: 0 0 auto plus a fixed width/height on each snap item to prevent Safari from treating them as zero-size.
Animation libraries (GSAP, Locomotive Scroll, Lenis) override the browser scroll — these libraries set up custom scroll handling and bypass scroll-behavior. The library’s own scrollTo API is required; window.scrollTo is intercepted. Check the library docs for the equivalent helper, or disable the smooth-scroll feature on routes where you need native behaviour.
A parent transform / will-change / overflow: hidden breaks scroll-snap — any ancestor with transform, filter, perspective, or will-change creates a containing block that can change which element is the actual scroll root. Inspect the scrolling ancestor in DevTools (the element with a visible scrollbar) and confirm your snap properties are on that element, not on a wrapper above or below it.
Anchor links smooth-scroll once, then jump on subsequent clicks — Chrome’s bfcache or a router intercepting clicks can cause this. Inspect the click handler — if a JS handler calls scrollTo without behavior: 'smooth', that overrides the CSS. Make handlers explicit instead of relying on CSS.
For related CSS layout issues, see Fix: CSS Flexbox Not Working, Fix: CSS Position Sticky Not Working, Fix: CSS Grid Not Working, and Fix: CSS Container Query Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CSS Grid Layout Not Working — Items Not Placing or Spanning Correctly
How to fix CSS Grid issues — implicit vs explicit grid, grid-template-areas, auto-placement, subgrid, alignment, and common mistakes with grid-column and grid-row.
Fix: Mantine Not Working — Styles Not Loading, Theme Not Applying, or Components Broken After Upgrade
How to fix Mantine UI issues — MantineProvider setup, PostCSS configuration, theme customization, dark mode, form validation with useForm, and Next.js App Router integration.
Fix: View Transitions API Not Working — No Animation Between Pages, Cross-Document Transitions Failing, or Fallback Missing
How to fix View Transitions API issues — same-document transitions, cross-document MPA transitions, view-transition-name CSS, Next.js and Astro integration, custom animations, and browser support.
Fix: Panda CSS Not Working — Styles Not Applying, Tokens Not Resolving, or Build Errors
How to fix Panda CSS issues — PostCSS setup, panda.config.ts token system, recipe and pattern definitions, conditional styles, responsive design, and integration with Next.js and Vite.