CSS Scroll Snap: Precise Scrolling Sections Without JavaScript
Master CSS Scroll Snap to build buttery-smooth, section-based scrolling experiences with zero JavaScript — just two properties and you're done.
What CSS Scroll Snap Actually Is
Scroll Snap lets you define a scroll container where scrolling "snaps" to fixed alignment points — think of it like a slider but implemented entirely in CSS. No IntersectionObserver. No requestAnimationFrame loops. No third-party library eating 40kb of your bundle.
It shipped in Chrome 69 (2018) and has had solid cross-browser support since Safari 11. You'd be surprised how many teams are still reaching for JavaScript scroll hijacking when this has been sitting in browsers for years, fully supported and fast.
The core idea is two CSS properties working together: scroll-snap-type on the container, and scroll-snap-align on the children. That's it. Two properties and you've got a full-page slider or a horizontal card reel that snaps cleanly between items.
Worth noting: this is native browser scrolling. The browser's own scroll physics, momentum, and accessibility handling all just work. Screen readers, keyboard navigation, touch devices — none of that breaks the way it does with JavaScript scroll hijacking.
The Syntax Breakdown
Start with your scroll container. You set scroll-snap-type to define the axis and how strict the snapping should be:
.scroll-container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
height: 100vh;
}The axis is either x, y, or both. The second value is either mandatory (always snaps, even mid-scroll) or proximity (snaps only if you stop close enough to a snap point). Honestly, mandatory is what you want for full-page sections. proximity is better for carousels where you want partial scrolling to feel natural.
Then on each child, you declare where it should snap to:
.section {
scroll-snap-align: start;
height: 100vh;
width: 100%;
}scroll-snap-align takes start, center, or end. For full-page sections, start aligns the top of each section to the top of the viewport. center is fantastic for card carousels where you want the active card centered. One more thing — you can also add scroll-snap-stop: always on children if you want to prevent the user from skipping snap points in one fast swipe.
Quick aside: scroll-padding on the container is your friend when you have a fixed header. If your nav is 64px tall, set scroll-padding-top: 64px and snap points will account for it automatically.
Full-Page Vertical Sections
This is the most common use case — a landing page where each section takes the full viewport and scrolling locks between them. Here's a complete working example:
<div class="snap-container">
<section class="snap-section hero">Hero</section>
<section class="snap-section features">Features</section>
<section class="snap-section pricing">Pricing</section>
<section class="snap-section contact">Contact</section>
</div>.snap-container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
height: 100vh;
/* Hide scrollbar visually but keep it functional */
scrollbar-width: none;
}
.snap-container::-webkit-scrollbar {
display: none;
}
.snap-section {
scroll-snap-align: start;
scroll-snap-stop: always;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}That scroll-snap-stop: always prevents users from momentum-scrolling past multiple sections in one flick — which is almost always what you want on a marketing page. Without it, a fast swipe on mobile can skip sections entirely.
In practice, you'll want to pair this with some CSS entry animations. When a section snaps into view, triggering an @keyframes animation on the content feels native. Check out css-scroll-animations if you want to combine Scroll Snap with scroll-driven animations — they compose cleanly together since Chrome 115.
Horizontal Card Carousels
Horizontal scroll snap is where this feature really shines for component-level UI. Instead of a JavaScript carousel with dot indicators and click handlers, you get this:
.card-reel {
display: flex;
gap: 16px;
overflow-x: scroll;
scroll-snap-type: x mandatory;
padding: 0 24px;
scrollbar-width: none;
}
.card {
flex-shrink: 0;
width: 300px;
scroll-snap-align: center;
border-radius: 12px;
background: white;
padding: 24px;
}Notice flex-shrink: 0 on the cards — without it, flexbox will squish them and your snap points won't align to what you think they will. Classic gotcha.
Look, this pattern replaces a substantial chunk of what people install Swiper or Embla Carousel for. You lose some features — no autoplay, no dot indicators out of the box — but you gain zero JavaScript, zero bundle size, and perfect browser scroll physics. For simple product cards or testimonials, it's a clean win.
If you're building UI components that use this pattern — especially anything with that tactile, polished feel — the glassmorphism components on Empire UI use layered glass cards that work beautifully in a horizontal scroll reel. The translucency effect as cards scroll past each other looks particularly good.
Scroll Snap + CSS Custom Properties
Where it gets interesting is combining Scroll Snap with CSS custom properties to build responsive, themeable scroll experiences. You can control snap behavior at different breakpoints without touching JavaScript:
:root {
--snap-type: none;
}
@media (min-width: 768px) {
:root {
--snap-type: y mandatory;
}
}
.scroll-container {
scroll-snap-type: var(--snap-type);
overflow-y: scroll;
height: 100vh;
}This disables snapping on mobile (where full-page snap can feel claustrophobic on small screens) and enables it on desktop. The UX win here is real — on a 375px wide phone, snapping every section at 100vh can feel aggressive. On a 1440px desktop monitor, it's elegant.
That said, don't reflexively disable it on mobile. Test it. Some designs — especially portfolio sites or storytelling pages — actually feel great with mandatory snap on touch devices. The browser's native momentum makes it work.
Common Pitfalls to Watch Out For
The single most common bug: your snap container isn't actually scrolling. If overflow: scroll is set but the container doesn't have a constrained height, there's nothing to scroll and snap does nothing. Always verify the container has a fixed height — 100vh, 400px, whatever — and that children actually overflow it.
Another one: scroll snap fighting with your layout. If you have margin-bottom on a snap child, that margin becomes part of the snap calculation. Gaps between sections? Use gap on a flex or grid container instead of margins on individual children — it's more predictable.
/* This causes snap point drift — margins shift the snap calculation */
.snap-section {
margin-bottom: 20px; /* don't do this */
}
/* Use gap on the container instead */
.snap-container {
display: flex;
flex-direction: column;
gap: 20px; /* predictable snap behavior */
}Overscroll behavior is worth considering too. On some mobile browsers, scroll-snap-type: y mandatory on a child container can conflict with the page-level overscroll pull-to-refresh. Adding overscroll-behavior-y: contain on your snap container prevents the scroll from bubbling up to the page.
One more thing — Safari before version 15 has some quirks with scroll-snap-stop. If you need to support older Safari (enterprise projects, I see you), test specifically on Safari 14 and fall back to just scroll-snap-align. The snapping still works, you just lose the "can't skip sections" enforcement.
When to Use It and When to Reach for JavaScript
CSS Scroll Snap covers a surprisingly wide range of use cases. Full-page landing sections, horizontal card carousels, image galleries, onboarding flows — all of these work with pure CSS. If you need snapping plus some visual feedback on the current section (like dot indicators), you can pair it with the :target pseudo-class or even a small, focused IntersectionObserver just for the indicator state. That's not "JavaScript scroll hijacking" — it's JavaScript observing, not controlling.
Where you genuinely need JavaScript: programmatic scrolling to a specific section (though scrollTo with behavior: 'smooth' works fine), keyboard navigation beyond what the browser provides, or complex physics like parallax within sections. For anything parallax, scroll-driven animations in CSS are increasingly handling what used to require libraries — worth exploring before pulling in a dependency.
If you're building a full design system or component library, consider browse components at Empire UI — the components there are built with this kind of CSS-first thinking. Minimal JavaScript, maximum browser native behavior. The box shadow generator and other tools also follow this pattern — CSS output you can drop directly into your snap sections.
Honestly, the question isn't "CSS vs JavaScript" for scrolling — it's "does the browser already do this well?" For snapping, it does. Start there, add JavaScript only for the pieces CSS genuinely can't handle, and your users get a faster, more accessible experience with less code to maintain.
FAQ
Yes, it's well-supported on iOS Safari 11+ and Android Chrome. Touch momentum works natively — the browser handles it for you, which is actually smoother than most JavaScript alternatives.
Mandatory always snaps to the nearest snap point when scrolling stops, even if you barely moved. Proximity only snaps if you stop close enough to a snap point — it leaves partial positions intact if you stop in the middle.
Yes — pair scroll snap with scroll-driven animations using animation-timeline: scroll() in Chrome 115+. For broader browser support, a lightweight IntersectionObserver to add a CSS class works well too.
No — it's native browser scrolling, so keyboard navigation and screen readers work as expected. JavaScript scroll hijacking is what typically breaks accessibility; CSS snap avoids that entirely.