Tutorials / / 6 min read
How to create an interactive testimonial with Astro and Tailwind CSS: JavaScript and Alpine.js
Build an interactive testimonial slider with Astro and Tailwind CSS, two ways: vanilla JavaScript or Alpine.js, switching quotes with avatar buttons.
Let’s build a fun interactive testimonial section with Astro and Tailwind CSS: click an avatar and the quote changes. We’ll wire up the interactivity twice, first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.
What is an interactive testimonial?
Testimonials are quotes from clients or users that showcase your work and build trust, whether as a quote, a video, or a written review. An interactive version displays one at a time and lets visitors flip through them, here by clicking the author’s avatar, so the section stays compact while fitting several voices.
Use cases
- Landing pages: social proof right where visitors decide whether to trust you.
- Product pages: highlight the benefits real users actually noticed.
- Case studies and blog posts: back your claims with quotes from clients.
- Customer loyalty: showing off happy clients encourages repeat business.
The data
Since we are in Astro, we’ll put the testimonials in an array and map over it in the template, so we don’t have to write the same markup over and over. Each object in the collection includes:
id: unique identifier for each testimonial.content: the testimonial text.imageUrl: link or path to the avatar image.name: the name of the testimonial provider.title: their professional title or role.
{
id: 1,
content:
"Johnny is like a unicorn in a field of horses, magically bridging the gap between design and coding...",
imageUrl:
"https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8MjN8fGF2YXRhcnxlbnwwfHwwfHx8MA%3D%3D",
name: "Jhonny Mnemonic",
title: "Chief Wizard of Nowhere Land",
},The markup
The testimonial text
class="testimonial ...": the class the script uses to find each quote.data-index: marks which testimonial each block belongs to.style="display: none;": hides every quote initially; the script reveals the active one.
Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.
<!-- Dynamically render testimonials -->
{ testimonials.map((testimonial, index) => (
<div class="testimonial ..." data-index={index + 1} style="display: none;">
<p>{testimonial.content}</p>
</div>
)) }The avatar buttons
class="avatar-button ...": the buttons that change the active testimonial, one per author, each showing an avatar.
Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.
<!-- Dynamically render name and role -->
{ testimonials.map((testimonial, index) => (
<button class="avatar-button ..." data-index={index + 1}>
<img src={testimonial.imageUrl} alt="#_" />
</button>
)) }The testimonial info
class="testimonial-info ...": the block with the author’s name and role.style="display: none;": hidden initially, just like the quotes.
Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.
<!-- Dynamically render name and role -->
{ testimonials.map((testimonial, index) => (
<div
class="testimonial-info ..."
data-index={index + 1}
style="display: none;"
>
<h2>{testimonial.name}</h2>
<a href="#">{testimonial.title}</a>
</div>
)) }The full structure
<div class="w-full max-w-xl pt-12 mx-auto mt-6 border-t">
<!-- Dynamically render testimonials -->
{ testimonials.map((testimonial, index) => (
<div
class="items-center h-32 max-w-2xl pb-6 mx-auto font-medium text-center testimonial text-zinc-500 text-balance"
data-index={index + 1}
style="display: none;"
>
<p>{testimonial.content}</p>
</div>
)) }
<div class="flex items-center justify-center mt-12">
<!-- Buttons to change the active testimonial -->
{ testimonials.map((testimonial, index) => (
<button
class="inline-block mx-2 font-bold text-center rounded-full avatar-button focus:outline-none focus:ring-2 ring-offset-4 ring-offset-white ring-orange-600 size-12"
data-index={index + 1}
>
<img
class="inline-block object-cover rounded-full size-12"
src={testimonial.imageUrl}
alt="#_"
/>
</button>
)) }
</div>
<!-- Dynamically render name and role -->
{ testimonials.map((testimonial, index) => (
<div
class="py-6 text-center testimonial-info"
data-index={index + 1}
style="display: none;"
>
<h2 class="text-base font-medium text-foreground ">{testimonial.name}</h2>
<a href="#" class="text-xs text-orange-500"> {testimonial.title} </a>
</div>
)) }
</div>The script
document.querySelectorAll(".testimonial"),.testimonial-info, and.avatar-button: grab all the quotes, info blocks, and buttons.showTestimonial(index): shows the quote and info whosedata-indexmatches, and hides the rest.- Each avatar button gets a click listener that prevents the default behavior and calls
showTestimonialwith its index. showTestimonial(1);: shows the first testimonial on load.
document.addEventListener("DOMContentLoaded", () => {
const testimonials = document.querySelectorAll(".testimonial");
const testimonialInfos = document.querySelectorAll(".testimonial-info");
const avatarButtons = document.querySelectorAll(".avatar-button");
const showTestimonial = (index) => {
testimonials.forEach((testimonial) => {
testimonial.style.display =
testimonial.dataset.index == index ? "block" : "none";
});
testimonialInfos.forEach((info) => {
info.style.display = info.dataset.index == index ? "block" : "none";
});
};
avatarButtons.forEach((button) => {
button.addEventListener("click", (e) => {
e.preventDefault();
const index = button.dataset.index;
showTestimonial(index);
});
});
showTestimonial(1);
});The Alpine.js version
Same component, no separate script: the state lives in x-data.
x-data="{ testimonialActive: 1 }": tracks the currently active testimonial. Adjust the number to set a different default.x-show: dynamically shows or hides a testimonial based on whether its index matches thetestimonialActivestate.@click.prevent: changes the active testimonial based on the user’s selection.
<div x-data="{ testimonialActive: 1 }"></div>The conditional rendering on each block:
<div x-show={`testimonialActive === ${index + 1}`}>The complete component for rendering testimonials dynamically:
<!-- Dynamically render testimonials -->
{
testimonials.map((testimonial, index) => (
<div
class="items-center pb-6 mx-auto text-4xl italic font-medium text-center text-zinc-200 lg:h-64 serif text-balance "
x-show={`testimonialActive === ${index + 1}`}
style="display: none;">
<p>
<span class="text-[#a180ea]">"</span>
{testimonial.content}
<span class="text-[#a180ea]">"</span>
</p>
</div>
))
}
<!-- Dynamically render name and role -->
{
testimonials.map((testimonial, index) => (
<div
class="py-6 text-center"
x-show={`testimonialActive === ${index + 1}`}
style="display: none;">
<h2 class="text-base font-medium text-background ">{testimonial.name}</h2>
<a
href="#"
class="text-xs text-zinc-500">
{testimonial.title}
</a>
</div>
))
}The event handling on the buttons:
<button @click.prevent="{`testimonialActive" ="${index" + 1}`}></button>And the navigation between testimonials:
<!--
Buttons to change the active testimonial
Dynamically render avatars
-->
{ testimonials.map((testimonial, index) => (
<button
@click.prevent="{`testimonialActive"
="${index"
+
1}`}
class="inline-block mx-2 font-bold text-center rounded-xl focus:outline-none focus:ring-2 ring-offset-2 ring-background ring-offset-[#141521] size-12"
>
<img
class="inline-block object-cover size-12 rounded-xl"
src={testimonial.imageUrl}
alt="#_"
/>
</button>
)) }You can try the Alpine version separately: live demo and source code.
Which one should you use?
Both versions map over the same Astro array; only the interactivity changes. With Alpine, x-show and one @click.prevent replace the whole script, so use it if it’s already loaded; otherwise the vanilla version keeps your page dependency-free.
Conclusion
This is a simple testimonial section that works anywhere you need to prove your value: products, services, blog posts. Before using it, make sure it’s fully accessible, then grab the code and do something cool with it!
Hope you enjoyed this tutorial and have a great day!
/Michael Andreuzza