How to build a Tailwind CSS + Alpine.js testimonial carousel (snap scroll)
Create a responsive testimonial carousel using Tailwind CSS and Alpine.js with snap scrolling, keyboard navigation, and smooth Prev/Next controls. Includes component-level snippets and a full example.
Published on November 14, 2025 by Michael AndreuzzaBuild a responsive testimonial carousel using Tailwind CSS for layout/spacing and Alpine.js for simple state and controls. This version uses horizontal snap scrolling so it’s smooth on trackpads and touch, while the Prev/Next buttons provide precise navigation.
Include Alpine.js
If Alpine.js isn’t already loaded globally, add it via CDN:
<script src="https://unpkg.com/alpinejs" defer></script>Optional: If you want to react to items entering/leaving the viewport with x-intersect:* (e.g., to adjust focusability), also include the Intersect plugin:
<script src="https://unpkg.com/@alpinejs/intersect" defer></script>Alpine.js state and methods
We’ll navigate by the width of a single card plus its CSS column gap. skip defines how many cards to advance per click.
<section
x-data="{
skip: 1,
next() { this.to((current, offset, slider) => {
const target = current + (offset * this.skip)
const max = slider.scrollWidth - slider.clientWidth
if (target >= max - 1) return 0
return target
})},
prev() { this.to((current, offset, slider) => {
const target = current - (offset * this.skip)
if (target <= 1) return slider.scrollWidth - slider.clientWidth
return target
})},
to(strategy) {
const slider = this.$refs.slider
const current = slider.scrollLeft
const first = slider.firstElementChild
const gap = parseFloat(getComputedStyle(slider).columnGap) || 0
const offset = first.getBoundingClientRect().width + gap
const target = strategy(current, offset, slider)
slider.scrollTo({ left: target, behavior: 'smooth' })
},
}"
>
<!-- controls + slider will go here -->
</section>Controls UI (Prev/Next)
Accessible buttons with clear labels. Tailwind classes add outline and hover states.
<div class="inline-flex items-center w-full mt-4 space-x-2">
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
x-on:click="prev"
aria-label="Previous testimonials"
>
<!-- left chevron -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler-chevron-left size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 6l-6 6l6 6"></path></svg>
</button>
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
x-on:click="next"
aria-label="Next testimonials"
>
<!-- right chevron -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler-chevron-right size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 6l6 6l-6 6"></path></svg>
</button>
<!-- Optional: change `skip` on the fly -->
<!-- <button @click="skip = 2" class="text-xs underline">Jump 2 cards</button> -->
</div>Slider container (snap scrolling)
Use horizontal overflow with snap alignment so cards settle predictably after touch/trackpad scrolls. x-ref="slider" gives methods access to the element.
<ul
class="relative flex w-full mt-4 overflow-x-scroll gap-3 scrollbar-hide snap-x snap-mandatory"
role="listbox"
aria-label="Testimonials"
tabindex="0"
x-ref="slider"
>
<!-- slides -->
</ul>Slide card (testimonial)
Each slide snaps into place with snap-start and doesn’t shrink. The figure stacks content on small screens and aligns side-by-side on large.
<li class="w-full shrink-0 snap-start" role="option">
<figure class="relative flex flex-col items-center lg:flex-row gap-12">
<div>
<blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
"Oxbow's components let us move from concept to production in days, not months."
</blockquote>
<div class="mt-6">
<h3 class="text-base font-semibold text-zinc-900">Ana Betancourt</h3>
<p class="text-sm font-medium text-zinc-600">Creator of Something</p>
</div>
</div>
<figcaption class="justify-between w-full">
<img
alt="Portrait of Ana Betancourt"
src="https://oxbowui.com/avatars/5.jpg"
class="object-cover size-full rounded-2xl aspect-[4/3]"
/>
</figcaption>
</figure>
</li>Keyboard navigation and region semantics
Wrap the slider and controls in a labeled region to improve keyboard discovery. Arrow keys can call prev/next.
<div
class="relative flex flex-col w-full"
role="region"
aria-labelledby="carousel-label"
tabindex="0"
x-on:keydown.left="prev"
x-on:keydown.right="next"
>
<h2 id="carousel-label" class="sr-only">Testimonials Carousel</h2>
<!-- controls + slider here -->
</div>Tuning card widths and jumps
- Show more cards: add fixed widths like
sm:w-1/2 lg:w-1/3to<li>or migrate to a grid track:grid grid-flow-col auto-cols-[80%] sm:auto-cols-[50%] lg:auto-cols-[33%]. - Jump multiple cards per click: set
skip = 2(or more) in state or toggle it via a button. - Adjust gaps: modify the
gap-*class; the JavaScript reads the computedcolumn-gapto calculate offsets.
Full copy‑paste example
All image URLs are absolute (prefixed with https://oxbowui.com/) so they don’t break. Replace them with your own when integrating.
<div class="flex flex-col w-full"
x-data="{
skip: 1,
next() {
this.to((current, offset, slider) => {
const target = current + (offset * this.skip)
const max = slider.scrollWidth - slider.clientWidth
if (target >= max - 1) return 0
return target
})
},
prev() {
this.to((current, offset, slider) => {
const target = current - (offset * this.skip)
if (target <= 1) return slider.scrollWidth - slider.clientWidth
return target
})
},
to(strategy) {
const slider = this.$refs.slider
const current = slider.scrollLeft
const first = slider.firstElementChild
const gap = parseFloat(getComputedStyle(slider).columnGap) || 0
const offset = first.getBoundingClientRect().width + gap
const target = strategy(current, offset, slider)
slider.scrollTo({ left: target, behavior: 'smooth' })
},
}"
>
<div
class="relative flex flex-col w-full"
aria-labelledby="carousel-label"
role="region"
tabindex="0"
x-on:keydown.left="prev"
x-on:keydown.right="next"
>
<h2 class="sr-only" id="carousel-label">Carousel</h2>
<span class="sr-only" id="carousel-content-label">Testimonials</span>
<div class="inline-flex items-center order-last w-full mt-4 space-x-2">
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
x-on:click="prev"
aria-label="Previous testimonials"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler-chevron-left size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 6l-6 6l6 6"></path></svg>
</button>
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
x-on:click="next"
aria-label="Next testimonials"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler-chevron-right size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 6l6 6l-6 6"></path></svg>
</button>
</div>
<ul
class="relative flex w-full mt-4 overflow-x-scroll gap-3 scrollbar-hide snap-mandatory snap-x"
role="listbox"
aria-labelledby="carousel-content-label"
tabindex="0"
x-ref="slider"
>
<li class="w-full shrink-0 snap-start" role="option">
<figure class="relative flex flex-col items-center lg:flex-row gap-12">
<div>
<blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
Oxbow's components let us move from concept to production in days, not months.
</blockquote>
<div class="mt-6">
<h3 class="text-base font-semibold text-zinc-900">Ana Betancourt</h3>
<p class="text-sm font-medium text-zinc-600">Creator of Something</p>
</div>
</div>
<figcaption class="justify-between w-full">
<img alt="Portrait of Ana" src="https://oxbowui.com/avatars/5.jpg" class="object-cover size-full rounded-2xl aspect-[4/3]" />
</figcaption>
</figure>
</li>
<li class="w-full shrink-0 snap-start" role="option">
<figure class="relative flex flex-col items-center lg:flex-row gap-12">
<div>
<blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
Adopting the toolkit reduced our UI debt and improved consistency across products.
</blockquote>
<div class="mt-6">
<h3 class="text-base font-semibold text-zinc-900">Juanjo Garcia</h3>
<p class="text-sm font-medium text-zinc-600">Founder</p>
</div>
</div>
<figcaption class="justify-between w-full">
<img alt="Portrait of Juanjo" src="https://oxbowui.com/avatars/2.jpg" class="object-cover size-full rounded-2xl aspect-[4/3]" />
</figcaption>
</figure>
</li>
<li class="w-full shrink-0 snap-start" role="option">
<figure class="relative flex flex-col items-center lg:flex-row gap-12">
<div>
<blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
The docs and defaults are excellent — our team onboarded in a single sprint.
</blockquote>
<div class="mt-6">
<h3 class="text-base font-semibold text-zinc-900">Maya Lin</h3>
<p class="text-sm font-medium text-zinc-600">Product Lead</p>
</div>
</div>
<figcaption class="justify-between w-full">
<img alt="Portrait of Maya" src="https://oxbowui.com/avatars/3.jpg" class="object-cover size-full rounded-2xl aspect-[4/3]" />
</figcaption>
</figure>
</li>
</ul>
</div>
</div>That’s it. You now have a lightweight, responsive testimonial carousel powered by Alpine.js and styled with Tailwind CSS. Adjust skip, gaps, and card widths to tune the interaction for your content.
/Michael Andreuzza