lexington®
Use code LEX35 at checkout
7k+ customers.
How to build an Alpine.js testimonial carousel with Tailwind CSS
Create a looping testimonial carousel powered by Alpine.js interactions and Tailwind CSS styling, complete with focus management and keyboard controls.
Published on October 8, 2025 by Michael AndreuzzaShowcase testimonials with Alpine.js + Tailwind CSS
Testimonials sell your product better than any marketing copy. In this tutorial we will recreate my Alpine.js carousel that keeps feedback cards in a horizontal track, adds buttery smooth scroll snapping, and exposes keyboard controls. The entire interaction weighs only a few lines of Alpine state plus Tailwind CSS utilities.
We will:
- Define Alpine.js helpers to skip through the carousel and wrap around when you hit either end
- Wire keyboard and button controls that mirror each other for accessibility
- Style the track with Tailwind CSS snap classes and responsive spacing
- Add focus guards so cards stay tabbable only when they are visible
Prerequisites: Tailwind CSS and Alpine.js need to be available in your project. If you are inside an Astro file, you can paste the markup below directly and everything will render server-side before Alpine takes over on the client.
1. Start with the Alpine shell
We keep the carousel state on the root element with x-data. The helper exposes next, prev, and a generic to method that calculates the target offset based on each card’s width and the gap between them.
<div
class="flex flex-col-reverse w-full"
x-data="{
skip: 1,
next() {
this.to((current, offset, slider) => {
const target = current + offset * this.skip;
const max = slider.scrollWidth - slider.clientWidth;
return target > max - 1 ? 0 : target;
});
},
prev() {
this.to((current, offset, slider) => {
const target = current - offset * this.skip;
return target < 0 ? Math.max(0, slider.scrollWidth - slider.clientWidth) : 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' });
},
focusableWhenVisible: {
'x-intersect:enter'() {
this.$el.removeAttribute('tabindex');
},
'x-intersect:leave'() {
this.$el.setAttribute('tabindex', '-1');
},
},
}"
>
<!-- content will go here -->
</div>
The optional focusableWhenVisible binding relies on the Alpine Intersect plugin. Attach it to the <li> elements so only the visible card stays in the tab order.
2. Build the heading and controls
Inside the root wrapper, set up a region with a heading, supporting copy, and two buttons. Both arrow buttons call the corresponding Alpine methods, and we mirror them with keyboard handlers for screen reader users.
<div
class="flex flex-col-reverse w-full"
aria-labelledby="carousel-label"
role="region"
tabindex="0"
x-on:keydown.left="prev"
x-on:keydown.right="next"
>
<div class="flex flex-col-reverse items-start lg:flex-row">
<div class="max-w-xs">
<h2 id="carousel-label" class="text-xl md:text-2xl lg:text-3xl font-semibold tracking-tight text-zinc-900 text-balance">
Stories from teams using Oxbow
</h2>
<p class="text-base mt-4 font-medium text-zinc-500">
Real-world examples of how product teams accelerate delivery with our UI toolkit.
</p>
<div class="inline-flex items-center order-last mt-4 space-x-2">
<button
type="button"
class="flex size-9 items-center justify-center rounded-md p-2 text-sm font-medium text-zinc-600 bg-zinc-50 outline outline-zinc-50 shadow-subtle transition-colors duration-500 ease-in-out hover:bg-zinc-200 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-600"
x-on:click="prev"
>
<span class="sr-only">Skip to previous slide page</span>
<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="size-5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 6l-6 6l6 6"></path>
</svg>
</button>
<button
type="button"
class="flex size-9 items-center justify-center rounded-md p-2 text-sm font-medium text-zinc-600 bg-zinc-50 outline outline-zinc-50 shadow-subtle transition-colors duration-500 ease-in-out hover:bg-zinc-200 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-600"
x-on:click="next"
>
<span class="sr-only">Skip to next slide page</span>
<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="size-5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 6l6 6l-6 6"></path>
</svg>
</button>
</div>
</div>
<!-- Slider track will sit to the right on large screens -->
</div>
</div>
Because the control wrapper sits inside the keyboard-enabled region, arrow key presses and button clicks share the same Alpine methods.
3. Lay out the testimonial track
The testimonial list uses grid-flow-col with auto-cols-max to create a horizontal strip of cards. We lean on Tailwind’s snap-mandatory utilities so each card snaps into place when the user drags or when Alpine scrolls programmatically.
<div class="w-full mt-12 lg:mt-0 py-2 lg:-mr-[90rem] lg:ml-32">
<ul
class="relative w-screen p-2 overflow-x-scroll gap-3 grid grid-flow-col auto-cols-max scrollbar-hide snap-mandatory"
role="listbox"
aria-labelledby="carousel-label"
tabindex="0"
x-ref="slider"
>
<template x-for="(person, index) in [
{
name: 'Mike Andreuzza',
role: 'Creator',
quote: "Implementing Semplice's blockchain technology has been a game-changer for our supply chain management.",
avatar: '/avatars/1.jpg',
},
{
name: 'Gege Piazza',
role: 'Developer',
quote: "Implementing Semplice's blockchain technology has been a game-changer for our supply chain management.",
avatar: '/avatars/2.jpg',
},
{
name: 'Jenson Button',
role: 'Founder',
quote: 'We were initially hesitant about integrating blockchain technology into our existing systems.',
avatar: '/avatars/3.jpg',
},
{
name: 'Johanna Manoy',
role: 'Designer',
quote: "Being in the financial industry, we were always looking for ways to enhance our transactions' security and efficiency.",
avatar: '/avatars/4.jpg',
},
]" :key="index">
<li
role="option"
aria-selected="false"
class="h-full w-72 snap-start rounded-xl bg-linear-180 from-zinc-50 to-zinc-100 p-8 shadow outline outline-zinc-100"
x-bind="focusableWhenVisible"
>
<figure class="flex h-full flex-col-reverse justify-between">
<figcaption class="relative mt-12 flex items-center gap-3">
<img :src="person.avatar" :alt="person.name" class="size-12 rounded-full object-cover" />
<div>
<h3 class="text-base font-medium text-zinc-900" x-text="person.name"></h3>
<p class="text-xs font-medium text-zinc-400" x-text="person.role"></p>
</div>
</figcaption>
<blockquote class="text-base font-semibold text-zinc-900" x-text="person.quote"></blockquote>
</figure>
</li>
</template>
</ul>
</div>
What to notice:
x-ref="slider"gives the Alpine methods a direct handle to the track so they can readscrollLeftandscrollWidth.auto-cols-maxcombined with a fixed card width (w-72) keeps the cards nicely spaced while still allowing them to scroll.- Scrollbar hiding is cosmetic; remove
scrollbar-hideif you prefer native controls.
4. Enhance accessibility and customization
Two finishing touches make the component production-ready:
- Focus management: The
focusableWhenVisibleobject togglestabindex="-1"on cards that leave the viewport. This keeps keyboard users from tabbing through dozens of off-screen testimonials. - Data-driven content: Swap the inline array for
x-data="carouselTestimonials"or fetch testimonials via Astro frontmatter. Alpine will happily consume any array you pass in.
You can also adjust the skip value to 2 if you want the carousel to jump two cards at a time, or change the tailwind classes to match your brand palette.
Full snippet to drop in an Astro component
Paste the combined markup inside an Astro page or .astro component. All Tailwind classes ship in the bundle, and Alpine hydrates once the client loads.
---
// No additional Astro frontmatter is required
---
<div
class="flex flex-col-reverse w-full"
x-data="{
skip: 1,
testimonials: [
{
name: 'Mike Andreuzza',
role: 'Creator',
quote: "Implementing Semplice's blockchain technology has been a game-changer for our supply chain management.",
avatar: '/avatars/1.jpg',
},
{
name: 'Gege Piazza',
role: 'Developer',
quote: "Implementing Semplice's blockchain technology has been a game-changer for our supply chain management.",
avatar: '/avatars/2.jpg',
},
{
name: 'Jenson Button',
role: 'Founder',
quote: 'We were initially hesitant about integrating blockchain technology into our existing systems.',
avatar: '/avatars/3.jpg',
},
{
name: 'Johanna Manoy',
role: 'Designer',
quote: "Being in the financial industry, we were always looking for ways to enhance our transactions' security and efficiency.",
avatar: '/avatars/4.jpg',
},
],
next() {
this.to((current, offset, slider) => {
const target = current + offset * this.skip;
const max = slider.scrollWidth - slider.clientWidth;
return target > max - 1 ? 0 : target;
});
},
prev() {
this.to((current, offset, slider) => {
const target = current - offset * this.skip;
return target < 0 ? Math.max(0, slider.scrollWidth - slider.clientWidth) : 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' });
},
focusableWhenVisible: {
'x-intersect:enter'() {
this.$el.removeAttribute('tabindex');
},
'x-intersect:leave'() {
this.$el.setAttribute('tabindex', '-1');
},
},
}"
>
<div
class="flex flex-col-reverse w-full"
aria-labelledby="carousel-label"
role="region"
tabindex="0"
x-on:keydown.left="prev"
x-on:keydown.right="next"
>
<div class="flex flex-col-reverse items-start lg:flex-row">
<div class="max-w-xs">
<h2 id="carousel-label" class="text-xl md:text-2xl lg:text-3xl font-semibold tracking-tight text-zinc-900 text-balance">
Stories from teams using Oxbow
</h2>
<p class="text-base mt-4 font-medium text-zinc-500">
Real-world examples of how product teams accelerate delivery with our UI toolkit.
</p>
<div class="inline-flex items-center order-last mt-4 space-x-2">
<button
type="button"
class="flex size-9 items-center justify-center rounded-md p-2 text-sm font-medium text-zinc-600 bg-zinc-50 outline outline-zinc-50 shadow-subtle transition-colors duration-500 ease-in-out hover:bg-zinc-200 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-600"
x-on:click="prev"
>
<span class="sr-only">Skip to previous slide page</span>
<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="size-5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 6l-6 6l6 6"></path>
</svg>
</button>
<button
type="button"
class="flex size-9 items-center justify-center rounded-md p-2 text-sm font-medium text-zinc-600 bg-zinc-50 outline outline-zinc-50 shadow-subtle transition-colors duration-500 ease-in-out hover:bg-zinc-200 focus:outline-2 focus:outline-offset-2 focus:outline-zinc-600"
x-on:click="next"
>
<span class="sr-only">Skip to next slide page</span>
<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="size-5">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 6l6 6l-6 6"></path>
</svg>
</button>
</div>
</div>
<div class="w-full mt-12 lg:mt-0 py-2 lg:-mr-[90rem] lg:ml-32">
<ul
class="relative w-screen p-2 overflow-x-scroll gap-3 grid grid-flow-col auto-cols-max scrollbar-hide snap-mandatory"
role="listbox"
aria-labelledby="carousel-label"
tabindex="0"
x-ref="slider"
>
<template x-for="(person, index) in testimonials" :key="index">
<li
role="option"
aria-selected="false"
class="h-full w-72 snap-start rounded-xl bg-linear-180 from-zinc-50 to-zinc-100 p-8 shadow outline outline-zinc-100"
x-bind="focusableWhenVisible"
>
<figure class="flex h-full flex-col-reverse justify-between">
<figcaption class="relative mt-12 flex items-center gap-3">
<img :src="person.avatar" :alt="person.name" class="size-12 rounded-full object-cover" />
<div>
<h3 class="text-base font-medium text-zinc-900" x-text="person.name"></h3>
<p class="text-xs font-medium text-zinc-400" x-text="person.role"></p>
</div>
</figcaption>
<blockquote class="text-base font-semibold text-zinc-900" x-text="person.quote"></blockquote>
</figure>
</li>
</template>
</ul>
</div>
</div>
</div>
</div>
Replace the sample testimonial objects with live data from your Astro content collections or CMS. If you already use Alpine stores, you can swap the inline array for testimonials: Alpine.store('myTestimonials') and everything else will continue to work.
This component stays feather-light, keeps testimonials in a loop, and demonstrates how Alpine.js pairs beautifully with Tailwind CSS for bespoke UI interactions.
/Michael Andreuzza