Scroll snap creates controlled scrolling experiences without JavaScript. Here's how to implement common patterns.
Basic Scroll Snap#
1/* Container */
2.scroll-container {
3 scroll-snap-type: x mandatory;
4 overflow-x: auto;
5 display: flex;
6}
7
8/* Items */
9.scroll-item {
10 scroll-snap-align: start;
11 flex-shrink: 0;
12 width: 100%;
13}
14
15/* Snap types */
16.mandatory {
17 /* Always snaps to nearest snap point */
18 scroll-snap-type: x mandatory;
19}
20
21.proximity {
22 /* Only snaps when close to snap point */
23 scroll-snap-type: x proximity;
24}
25
26/* Axes */
27.horizontal {
28 scroll-snap-type: x mandatory;
29}
30
31.vertical {
32 scroll-snap-type: y mandatory;
33}
34
35.both {
36 scroll-snap-type: both mandatory;
37}Horizontal Carousel#
1.carousel {
2 display: flex;
3 overflow-x: auto;
4 scroll-snap-type: x mandatory;
5 scroll-behavior: smooth;
6 gap: 1rem;
7
8 /* Hide scrollbar */
9 scrollbar-width: none;
10 -ms-overflow-style: none;
11}
12
13.carousel::-webkit-scrollbar {
14 display: none;
15}
16
17.carousel-item {
18 flex: 0 0 300px;
19 scroll-snap-align: start;
20}
21
22/* Center alignment */
23.carousel-centered {
24 scroll-snap-type: x mandatory;
25 scroll-padding-inline: calc(50% - 150px);
26}
27
28.carousel-centered .item {
29 scroll-snap-align: center;
30}
31
32/* Peek next item */
33.carousel-peek {
34 padding-inline: 1rem;
35}
36
37.carousel-peek .item {
38 flex: 0 0 calc(100% - 4rem);
39 scroll-snap-align: center;
40}Full-Page Sections#
1.fullpage {
2 height: 100vh;
3 overflow-y: auto;
4 scroll-snap-type: y mandatory;
5}
6
7.section {
8 height: 100vh;
9 scroll-snap-align: start;
10}
11
12/* Stop at specific point */
13.section-with-header {
14 scroll-snap-align: start;
15 scroll-margin-top: 60px; /* Account for fixed header */
16}
17
18/* Alternative: scroll-padding on container */
19.fullpage-with-header {
20 scroll-padding-top: 60px;
21 scroll-snap-type: y mandatory;
22}Image Gallery#
1.gallery {
2 display: grid;
3 grid-auto-flow: column;
4 grid-auto-columns: 100%;
5 overflow-x: auto;
6 scroll-snap-type: x mandatory;
7 scroll-behavior: smooth;
8}
9
10.gallery-image {
11 scroll-snap-align: center;
12 width: 100%;
13 height: 100%;
14 object-fit: cover;
15}
16
17/* With thumbnails */
18.gallery-main {
19 scroll-snap-type: x mandatory;
20 overflow-x: auto;
21}
22
23.gallery-thumb {
24 cursor: pointer;
25 opacity: 0.6;
26 transition: opacity 0.2s;
27}
28
29.gallery-thumb:hover,
30.gallery-thumb.active {
31 opacity: 1;
32}Card Slider#
1.card-slider {
2 display: flex;
3 overflow-x: auto;
4 scroll-snap-type: x mandatory;
5 gap: 1rem;
6 padding: 1rem;
7}
8
9/* Cards that show partial next card */
10.card-slider .card {
11 flex: 0 0 calc(100% - 3rem);
12 scroll-snap-align: start;
13}
14
15@media (min-width: 768px) {
16 .card-slider .card {
17 flex: 0 0 calc(50% - 1rem);
18 }
19}
20
21@media (min-width: 1024px) {
22 .card-slider .card {
23 flex: 0 0 calc(33.333% - 1rem);
24 }
25}Snap Stop#
1/* Prevent skipping items on fast scroll */
2.item {
3 scroll-snap-align: start;
4 scroll-snap-stop: always; /* Stop at every item */
5}
6
7/* Default behavior (can skip items) */
8.item-skip {
9 scroll-snap-align: start;
10 scroll-snap-stop: normal;
11}Scroll Padding and Margin#
1/* Container padding */
2.container {
3 scroll-snap-type: x mandatory;
4 scroll-padding-inline: 2rem;
5}
6
7/* Item margin */
8.item {
9 scroll-snap-align: start;
10 scroll-margin-inline-start: 1rem;
11}
12
13/* Use case: Fixed header */
14.page-sections {
15 scroll-snap-type: y mandatory;
16 scroll-padding-top: 80px; /* Header height */
17}
18
19.section {
20 scroll-snap-align: start;
21}Horizontal Timeline#
1.timeline {
2 display: flex;
3 overflow-x: auto;
4 scroll-snap-type: x mandatory;
5 padding: 2rem;
6}
7
8.timeline-item {
9 flex: 0 0 300px;
10 scroll-snap-align: center;
11 position: relative;
12 padding: 2rem;
13}
14
15.timeline-item::before {
16 content: '';
17 position: absolute;
18 top: 50%;
19 left: 0;
20 right: 0;
21 height: 2px;
22 background: #ddd;
23}
24
25.timeline-item::after {
26 content: '';
27 position: absolute;
28 top: 50%;
29 left: 50%;
30 transform: translate(-50%, -50%);
31 width: 12px;
32 height: 12px;
33 background: #3b82f6;
34 border-radius: 50%;
35}Tab Panels with Scroll Snap#
1.tabs {
2 display: flex;
3 overflow-x: auto;
4 scroll-snap-type: x mandatory;
5}
6
7.tab-panel {
8 flex: 0 0 100%;
9 scroll-snap-align: start;
10 min-height: 200px;
11 padding: 1rem;
12}
13
14/* Sync tabs with scroll position */
15.tab-buttons {
16 display: flex;
17 gap: 0.5rem;
18}
19
20.tab-button {
21 padding: 0.5rem 1rem;
22 border: none;
23 background: transparent;
24}
25
26.tab-button.active {
27 border-bottom: 2px solid #3b82f6;
28}Scroll Snap with JavaScript#
1// Detect current snap item
2const container = document.querySelector('.scroll-container');
3
4container.addEventListener('scrollend', () => {
5 const items = container.querySelectorAll('.item');
6 const containerRect = container.getBoundingClientRect();
7
8 items.forEach((item, index) => {
9 const itemRect = item.getBoundingClientRect();
10 const isVisible =
11 itemRect.left >= containerRect.left &&
12 itemRect.right <= containerRect.right;
13
14 if (isVisible) {
15 console.log('Current item:', index);
16 updateIndicators(index);
17 }
18 });
19});
20
21// Scroll to specific item
22function scrollToItem(index) {
23 const items = container.querySelectorAll('.item');
24 items[index]?.scrollIntoView({
25 behavior: 'smooth',
26 inline: 'start',
27 });
28}
29
30// Navigation buttons
31document.querySelector('.next').addEventListener('click', () => {
32 container.scrollBy({
33 left: container.clientWidth,
34 behavior: 'smooth',
35 });
36});
37
38document.querySelector('.prev').addEventListener('click', () => {
39 container.scrollBy({
40 left: -container.clientWidth,
41 behavior: 'smooth',
42 });
43});Intersection Observer Integration#
1// Track visible items
2const observer = new IntersectionObserver(
3 (entries) => {
4 entries.forEach((entry) => {
5 if (entry.isIntersecting) {
6 entry.target.classList.add('visible');
7 // Update pagination, analytics, etc.
8 } else {
9 entry.target.classList.remove('visible');
10 }
11 });
12 },
13 {
14 root: document.querySelector('.scroll-container'),
15 threshold: 0.5,
16 }
17);
18
19document.querySelectorAll('.item').forEach((item) => {
20 observer.observe(item);
21});Accessibility Considerations#
1<!-- Carousel with accessibility -->
2<div
3 class="carousel"
4 role="region"
5 aria-label="Image carousel"
6 tabindex="0"
7>
8 <div class="slide" role="group" aria-label="Slide 1 of 5">
9 <img src="image1.jpg" alt="Description of image 1">
10 </div>
11 <div class="slide" role="group" aria-label="Slide 2 of 5">
12 <img src="image2.jpg" alt="Description of image 2">
13 </div>
14</div>
15
16<!-- Navigation controls -->
17<div class="carousel-controls">
18 <button aria-label="Previous slide">Previous</button>
19 <button aria-label="Next slide">Next</button>
20</div>
21
22<!-- Pagination indicators -->
23<div class="pagination" role="tablist">
24 <button role="tab" aria-selected="true" aria-label="Go to slide 1">1</button>
25 <button role="tab" aria-selected="false" aria-label="Go to slide 2">2</button>
26</div>1/* Focus styles */
2.carousel:focus {
3 outline: 2px solid #3b82f6;
4 outline-offset: 2px;
5}
6
7/* Reduced motion */
8@media (prefers-reduced-motion: reduce) {
9 .carousel {
10 scroll-behavior: auto;
11 }
12
13 .carousel-item {
14 scroll-snap-stop: always;
15 }
16}Best Practices#
Implementation:
✓ Use mandatory for controlled UX
✓ Use proximity for flexible scrolling
✓ Add scroll-behavior: smooth
✓ Consider scroll-snap-stop: always
Accessibility:
✓ Provide keyboard navigation
✓ Add ARIA labels
✓ Respect reduced motion
✓ Include visible focus states
Performance:
✓ Use CSS-only when possible
✓ Debounce scroll event handlers
✓ Use Intersection Observer
✓ Avoid layout thrashing
Conclusion#
CSS Scroll Snap creates native-feeling scroll experiences without heavy JavaScript. Use it for carousels, galleries, and full-page sections. Combine with JavaScript for navigation controls and accessibility enhancements.