CSS Scroll Snap: Carousels, Galleries, and Full-Page Sections
CSS Scroll Snap gives you native browser-level snapping behavior — no JavaScript carousel library needed. Elements "snap" to defined positions as the user scrolls, creating smooth, buttery experiences for carousels, horizontal galleries, and full-page scroll sections.
1. The Two Core Properties
Everything in scroll snap comes down to two properties:
/* On the SCROLL CONTAINER */
scroll-snap-type: [axis] [strength];
/* axis: x y both block inline */
/* strength: mandatory proximity */
/* On each CHILD element (the snap targets) */
scroll-snap-align: [start | center | end | none];
Axis Values
scroll-snap-type: x mandatory; /* Horizontal snapping (carousel) */
scroll-snap-type: y mandatory; /* Vertical snapping (full-page sections) */
scroll-snap-type: both mandatory; /* Both axes (2D scroll grid) */
Strength Values
scroll-snap-type: x mandatory;
/* mandatory: ALWAYS snaps — container rests exactly at a snap point.
Even tiny scrolls jump to the next item. Best for full-page sections. */
scroll-snap-type: x proximity;
/* proximity: Snaps only when NEAR a snap point (browser decides threshold).
Light touch — snaps only if you scroll close enough. Best for long lists. */
2. The Scroll Snap Alignment Options
/* Applied to each child (snap target) */
scroll-snap-align: start;
/* The START edge of the child aligns with the container's start edge */
/* → Left edge of card aligns with left edge of container */
scroll-snap-align: center;
/* The CENTER of the child aligns with the center of the container */
/* → Middle of card aligns with middle of container */
scroll-snap-align: end;
/* The END edge of the child aligns with the container's end edge */
/* → Right edge of card aligns with right edge of container */
3. Pattern 1: Horizontal Card Carousel
The most common use case — clients always request this for testimonials, features, pricing.
<div class="c-carousel">
<div class="c-carousel__track">
<article class="c-carousel__item c-card">
<img class="c-card__image" src="img1.jpg" alt="">
<div class="c-card__body">
<h3 class="c-card__title">Card Title</h3>
<p class="c-card__text">Description text here.</p>
</div>
</article>
<article class="c-carousel__item c-card">...</article>
<article class="c-carousel__item c-card">...</article>
<article class="c-carousel__item c-card">...</article>
<article class="c-carousel__item c-card">...</article>
</div>
</div>
/* Carousel container */
.c-carousel {
position: relative;
overflow: hidden; /* Hide scrollbar overflow */
}
/* The scrollable track */
.c-carousel__track {
display: flex;
gap: 1.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory; /* Horizontal, always snaps */
scroll-behavior: smooth; /* Smooth scroll on programmatic scroll */
-webkit-overflow-scrolling: touch; /* iOS momentum scrolling */
/* Hide scrollbar (cross-browser) */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
padding-block: 1rem; /* Prevent card shadows from clipping */
padding-inline: 1.5rem; /* Add side padding for first/last card */
}
.c-carousel__track::-webkit-scrollbar { display: none; } /* Chrome/Safari */
/* Each snap target */
.c-carousel__item {
scroll-snap-align: start;
flex-shrink: 0; /* Prevent cards from shrinking */
width: clamp(280px, 80vw, 360px); /* Responsive card width */
}
/* Show partial next card — hinting there's more to scroll */
/* Achieved by using container padding + negative margin trick */
.c-carousel {
padding-inline: 1.5rem;
}
.c-carousel__track {
margin-inline: -1.5rem;
padding-inline: 1.5rem;
}
Carousel with Navigation Dots
<div class="c-carousel" id="testimonial-carousel">
<div class="c-carousel__track js-carousel-track">
<div class="c-carousel__item">...</div>
<div class="c-carousel__item">...</div>
<div class="c-carousel__item">...</div>
</div>
<div class="c-carousel__dots js-carousel-dots" aria-label="Carousel controls">
<button class="c-carousel__dot is-active" data-index="0" aria-label="Go to slide 1"></button>
<button class="c-carousel__dot" data-index="1" aria-label="Go to slide 2"></button>
<button class="c-carousel__dot" data-index="2" aria-label="Go to slide 3"></button>
</div>
</div>
.c-carousel__dots {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.c-carousel__dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--color-border-strong);
border: none;
cursor: pointer;
transition: width 0.3s ease, background 0.3s ease;
padding: 0;
}
.c-carousel__dot.is-active {
width: 24px;
border-radius: var(--radius-full);
background: var(--color-primary);
}
// Minimal JS for dot navigation
const track = document.querySelector('.js-carousel-track');
const dots = document.querySelectorAll('.js-carousel-dots .c-carousel__dot');
// Scroll to slide when dot clicked
dots.forEach((dot, i) => {
dot.addEventListener('click', () => {
const items = track.querySelectorAll('.c-carousel__item');
items[i].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
});
});
// Update active dot on scroll (IntersectionObserver)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = [...track.children].indexOf(entry.target);
dots.forEach(d => d.classList.remove('is-active'));
if (dots[index]) dots[index].classList.add('is-active');
}
});
}, { root: track, threshold: 0.6 });
track.querySelectorAll('.c-carousel__item').forEach(item => observer.observe(item));
4. Pattern 2: Full-Page Vertical Scroll Sections
Each section takes up the full viewport height and snaps into place:
<div class="c-fullpage">
<section class="c-fullpage__section c-hero">
<h1>Hero Section</h1>
</section>
<section class="c-fullpage__section c-features">
<h2>Features</h2>
</section>
<section class="c-fullpage__section c-pricing">
<h2>Pricing</h2>
</section>
<section class="c-fullpage__section c-contact">
<h2>Contact</h2>
</section>
</div>
/* Full-page scroll container */
.c-fullpage {
height: 100dvh; /* Full viewport height */
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
/* Hide scrollbar */
scrollbar-width: none;
}
.c-fullpage::-webkit-scrollbar { display: none; }
/* Each full-page section */
.c-fullpage__section {
height: 100dvh; /* Each section = full viewport */
scroll-snap-align: start;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden; /* Prevent section content from showing next section */
}
[!IMPORTANT] Use
scroll-snap-type: y mandatoryfor full-page sections.proximitymay not snap reliably when sections are exactly 100dvh tall.
5. Pattern 3: Horizontal Photo Gallery
A full-width gallery where each image fills the viewport width:
<div class="c-gallery">
<figure class="c-gallery__item">
<img class="c-gallery__img" src="photo1.jpg" alt="Photo 1">
<figcaption class="c-gallery__caption">Caption one</figcaption>
</figure>
<figure class="c-gallery__item">
<img class="c-gallery__img" src="photo2.jpg" alt="Photo 2">
<figcaption class="c-gallery__caption">Caption two</figcaption>
</figure>
</div>
.c-gallery {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
height: 70dvh;
scrollbar-width: none;
cursor: grab;
}
.c-gallery:active { cursor: grabbing; }
.c-gallery::-webkit-scrollbar { display: none; }
.c-gallery__item {
flex-shrink: 0;
width: 100vw; /* Each image: full viewport width */
scroll-snap-align: center;
position: relative;
overflow: hidden;
margin: 0;
}
.c-gallery__img {
width: 100%; height: 100%;
object-fit: cover;
object-position: center;
pointer-events: none; /* Prevent drag issues */
}
.c-gallery__caption {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 2rem;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
color: white;
font-size: var(--text-sm);
transform: translateY(0);
transition: transform 0.3s;
}
6. Pattern 4: Responsive Snap Grid (Mobile → Desktop)
Snapping on mobile, regular grid on desktop:
<div class="c-snap-grid">
<div class="c-snap-grid__item c-card">Card 1</div>
<div class="c-snap-grid__item c-card">Card 2</div>
<div class="c-snap-grid__item c-card">Card 3</div>
<div class="c-snap-grid__item c-card">Card 4</div>
<div class="c-snap-grid__item c-card">Card 5</div>
<div class="c-snap-grid__item c-card">Card 6</div>
</div>
/* Mobile: horizontal snap carousel */
.c-snap-grid {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 1rem;
scrollbar-width: none;
}
.c-snap-grid::-webkit-scrollbar { display: none; }
.c-snap-grid__item {
flex-shrink: 0;
width: min(280px, 85vw);
scroll-snap-align: start;
}
/* Tablet: 2-column grid — disable snap */
@media (min-width: 640px) {
.c-snap-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
overflow-x: visible;
scroll-snap-type: none;
}
.c-snap-grid__item {
width: auto;
scroll-snap-align: none;
}
}
/* Desktop: 3-column grid */
@media (min-width: 1024px) {
.c-snap-grid { grid-template-columns: repeat(3, 1fr); }
}
7. Scroll Snap Utilities
scroll-snap-stop
/* Force the user to stop at EVERY snap point (can't skip) */
.c-carousel__item {
scroll-snap-align: start;
scroll-snap-stop: always; /* Never skip this snap point */
}
/* Default behavior: user can momentum-scroll through multiple items */
.c-carousel__item {
scroll-snap-stop: normal; /* Can skip snap points with fast scroll */
}
scroll-padding (Critical for Fixed Headers)
When you have a sticky nav, snap targets get hidden under it. Fix with scroll-padding:
/* Tell the browser: when snapping, offset by nav height */
.c-fullpage {
scroll-snap-type: y mandatory;
scroll-padding-top: var(--nav-height, 70px); /* Match your nav height */
}
/* Or on the html element for anchor links */
html {
scroll-padding-top: 80px;
}
scroll-margin (Per-Element Offset)
/* Individual elements can define their own snap offset */
.c-fullpage__section {
scroll-snap-align: start;
scroll-margin-top: 70px; /* Push snap point 70px from container edge */
}
8. Browser Support and Accessibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
scroll-snap-type | ✅ v69 | ✅ v68 | ✅ v11 | ✅ v79 |
scroll-snap-align | ✅ | ✅ | ✅ | ✅ |
scroll-snap-stop | ✅ | ✅ | ✅ v15.4 | ✅ |
scroll-padding | ✅ | ✅ | ✅ | ✅ |
| Overall | ~96% |
Accessibility Requirements
/* Always respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.c-carousel__track,
.c-fullpage {
scroll-behavior: auto; /* No smooth-scroll (instant) */
scroll-snap-type: none; /* Disable snapping entirely */
}
}
<!-- Carousels need proper ARIA for screen readers -->
<div class="c-carousel"
role="region"
aria-label="Customer testimonials"
aria-roledescription="carousel">
<div class="c-carousel__track"
aria-live="polite">
<article class="c-carousel__item"
role="group"
aria-label="Testimonial 1 of 5"
aria-roledescription="slide">
...
</article>
</div>
</div>
9. WordPress Implementation
/* Testimonials carousel in WordPress (no plugin needed) */
.wp-block-query .c-carousel__track,
.testimonial-block .c-carousel__track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 1.5rem;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding-block: 1rem;
}
.wp-block-query .c-carousel__track::-webkit-scrollbar { display: none; }
/* Bricks Builder: add class via Custom Atributes panel */
/* Add class "c-carousel__track" to the inner repeater container */
/* Add class "c-carousel__item" to each post item */
10. AI Prompt for Scroll Snap Components
Build a horizontal scroll carousel using native CSS scroll snap.
Requirements:
- Horizontal scroll, snaps to each card (mandatory)
- Cards: [DESCRIBE CARD CONTENT AND SIZE]
- Show partial next card to hint at more content
- Navigation dots (CSS-styled, JS-updated via IntersectionObserver)
- Prev/Next arrow buttons (optional)
- Mobile: snap enabled. Desktop (>1024px): convert to CSS grid, disable snap
- Hide scrollbar cross-browser
- Respect prefers-reduced-motion (disable snap + smooth-scroll)
- ARIA attributes for accessibility
- Use my tokens: [PASTE :root]
Output: HTML + CSS + minimal JS (dots + IntersectionObserver only)