Scroll-snap testimonial carousel
A swipeable testimonial carousel built on native CSS scroll snapping, with optional previous and next buttons and no slider library.
Tailwind CSSCardscarouseltestimonialsscroll-snapresponsive
Live preview
Code
<section aria-roledescription="carousel" aria-label="Customer testimonials" class="mx-auto max-w-3xl px-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-900">What customers say</h2>
<div class="flex gap-2">
<button type="button" data-dir="-1" aria-label="Previous testimonials"
class="rounded-full border border-slate-300 px-3 py-1 text-slate-700 hover:bg-slate-100">←</button>
<button type="button" data-dir="1" aria-label="Next testimonials"
class="rounded-full border border-slate-300 px-3 py-1 text-slate-700 hover:bg-slate-100">→</button>
</div>
</div>
<!-- The track: native scroll with snap points, no library needed -->
<ul id="track" tabindex="0"
class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth pb-3 [scrollbar-width:none] focus:outline-2 focus:outline-indigo-500">
<li class="w-[85%] shrink-0 snap-center rounded-xl border border-slate-200 bg-white p-5 shadow-sm sm:w-80">
<p class="text-slate-700">"Setup took ten minutes and support answered the same day."</p>
<p class="mt-4 text-sm font-medium text-slate-900">Jordan Lee <span class="font-normal text-slate-500">· Northwind</span></p>
</li>
<li class="w-[85%] shrink-0 snap-center rounded-xl border border-slate-200 bg-white p-5 shadow-sm sm:w-80">
<p class="text-slate-700">"Our reporting time dropped by half after the first week."</p>
<p class="mt-4 text-sm font-medium text-slate-900">Priya Nair <span class="font-normal text-slate-500">· Acme</span></p>
</li>
<li class="w-[85%] shrink-0 snap-center rounded-xl border border-slate-200 bg-white p-5 shadow-sm sm:w-80">
<p class="text-slate-700">"The cleanest tool our team has adopted in years."</p>
<p class="mt-4 text-sm font-medium text-slate-900">Sam Ortiz <span class="font-normal text-slate-500">· Globex</span></p>
</li>
<li class="w-[85%] shrink-0 snap-center rounded-xl border border-slate-200 bg-white p-5 shadow-sm sm:w-80">
<p class="text-slate-700">"Simple, fast and it just works on mobile."</p>
<p class="mt-4 text-sm font-medium text-slate-900">Mei Tan <span class="font-normal text-slate-500">· Initech</span></p>
</li>
</ul>
</section>
<script>
// Buttons scroll by one card width; keyboard users can use arrow keys on the focused track
const track = document.getElementById('track');
document.querySelectorAll('[data-dir]').forEach((btn) => {
btn.addEventListener('click', () => {
const card = track.firstElementChild.getBoundingClientRect().width + 16;
track.scrollBy({ left: card * Number(btn.dataset.dir) });
});
});
</script>
How it works
What it solves
Most carousel plugins ship kilobytes of JavaScript to do something the browser already does: scroll horizontally and stop on item boundaries. This version relies on CSS scroll snapping, so swipe, trackpad and keyboard scrolling all work with momentum and no layout thrash.
How it works
The ul is a flex row with overflow-x-auto and snap-x snap-mandatory; each card declares snap-center and shrink-0, so the scroll position always settles with one card centered. On phones a card is 85% wide, which leaves a visible sliver of the next one as a swipe hint; from the sm breakpoint it is a fixed w-80. The tiny script only powers the arrow buttons: it measures the first card plus the 16px gap and calls scrollBy, so the step stays correct if you change the card width. Hiding the scrollbar uses an arbitrary property class.
Adapting it
- Change the card width classes to show two or more cards at once on desktop.
- Generate the
liitems from your reviews collection in a loop. - Remove the script and buttons entirely if swiping is enough for your audience.
Pitfalls
- The track has
tabindex="0"so keyboard users can scroll it; keep a visible focus outline. - Do not hide the scrollbar without offering the buttons, or mouse users may not discover more cards.
scroll-smoothshould respectprefers-reduced-motion; addmotion-reduce:scroll-autoif needed.
How to use it
- Drop the section into any page that loads Tailwind CSS 4.
- Loop your testimonial records into the
lielements, keeping the same classes. - Keep the id
trackor update the script selector if you rename it. - For several carousels on one page, query by a class and scope each button pair to its section.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.