This Astro carousel uses CSS scroll snap instead of a slider library. The track is a horizontally scrolling list with scroll-snap-type, so it swipes naturally on touch devices, scrolls with a trackpad or keyboard, and needs no JavaScript at all. If you want previous/next buttons, a short optional script scrolls the track by one card. Use it for testimonials, logos or product cards.
Key Takeaways
overflow-x: autoplusscroll-snap-type: x mandatoryis a complete carousel.- Every card stays in the HTML, so content is crawlable and accessible.
- Make the track focusable (
tabindex="0") so keyboard users can scroll it.- Avoid autoplay; it’s hard to read and an accessibility problem.
The component
---
// src/components/Carousel.astro
interface Props { label: string }
const { label } = Astro.props;
---
<section class="carousel" aria-label={label}>
<div class="carousel-controls">
<button type="button" class="carousel-prev" aria-label="Previous">←</button>
<button type="button" class="carousel-next" aria-label="Next">→</button>
</div>
<ul class="carousel-track" tabindex="0">
<slot />
</ul>
</section>
<style>
.carousel-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: min(85%, 22rem);
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scroll-padding-inline: 1rem;
padding: 0.5rem 1rem 1rem;
margin: 0;
list-style: none;
scrollbar-width: thin;
}
.carousel-track > :global(*) { scroll-snap-align: start; }
.carousel-track:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.carousel-controls { display: none; justify-content: flex-end; gap: 0.5rem; }
.carousel[data-enhanced] .carousel-controls { display: flex; }
.carousel-controls button { width: 2.5rem; height: 2.5rem; border-radius: 50%; border: 1px solid currentColor; background: none; cursor: pointer; }
@media (prefers-reduced-motion: no-preference) {
.carousel-track { scroll-behavior: smooth; }
}
</style>
<script>
document.querySelectorAll<HTMLElement>('.carousel').forEach((carousel) => {
const track = carousel.querySelector<HTMLElement>('.carousel-track')!;
const step = () => (track.firstElementChild as HTMLElement | null)?.offsetWidth ?? track.clientWidth;
carousel.querySelector('.carousel-prev')!.addEventListener('click', () => track.scrollBy({ left: -step() }));
carousel.querySelector('.carousel-next')!.addEventListener('click', () => track.scrollBy({ left: step() }));
carousel.dataset.enhanced = '';
});
</script>
The buttons only appear once the script runs (data-enhanced), so there are no dead controls without JavaScript.
A testimonial card
---
// src/components/Testimonial.astro
interface Props { quote: string; name: string; role: string }
const { quote, name, role } = Astro.props;
---
<li class="testimonial">
<blockquote><p>{quote}</p></blockquote>
<p class="who"><strong>{name}</strong>, {role}</p>
</li>
<style>
.testimonial { padding: 1.5rem; border: 1px solid var(--color-border, #e5e4df); border-radius: 0.75rem; }
blockquote { margin: 0; }
</style>
Using it
---
import Carousel from '../components/Carousel.astro';
import Testimonial from '../components/Testimonial.astro';
import { testimonials } from '../data/testimonials';
---
<Carousel label="Customer testimonials">
{testimonials.map((t) => <Testimonial {...t} />)}
</Carousel>
Only publish testimonials you have permission to use, with real names and roles.
Why not a slider library?
Slider libraries typically add JavaScript and CSS to every page that uses them, re-implement scrolling the browser already does well, and often hide slides from screen readers or break with touch scrolling. Scroll snap is supported in every current browser and costs nothing.
Showing more cards on wide screens
grid-auto-columns: min(85%, 22rem) shows one card plus a peek of the next on phones, and several cards on desktop. The peek is a useful hint that the row scrolls. For a fixed number per row, use calc((100% - 2rem) / 3) with a media query.
Accessibility notes
- The section has an
aria-label, and the track is focusable so arrow keys scroll it. - No autoplay: moving content that people didn’t start is hard to read and fails WCAG unless it can be paused.
- Cards are real list items, so screen readers announce the count.
Agency and portfolio sites often use this for client quotes; see portfolio themes. Script-versus-island reasoning is in island or script, and more patterns in the Astro components guide. MDN’s scroll snap guide explains the properties.
FAQ
Does a CSS scroll snap carousel work on mobile?
Yes. Touch scrolling and snapping are native, which usually feels better than script-driven sliders.
Can I add dots?
Yes, with an IntersectionObserver that marks which card is in view. Keep the dots as buttons that scroll to their card.