Summit Themes
Blog

How to build an Alpine.js testimonial carousel with Tailwind CSS

Testimonial sections are one of the most common components on a local business site, and they are also one of the easiest to get wrong — a wall of static quotes, or a bloated carousel plugin that pulls in jQuery just to swap a CSS class. Alpine.js and Tailwind CSS solve both problems cleanly. Alpine gives you just enough reactivity without the overhead, and Tailwind v4 keeps the styling colocated and fast.

This guide walks you through building a real testimonial carousel from scratch: auto-rotating slides, smooth transitions, dot navigation, and a data array you can hand off to a content author. All the code is copy-pasteable and correct as of Alpine.js 3.x and Tailwind CSS v4.

What you are building

A three-testimonial carousel that:

  • Auto-advances every seven seconds
  • Pauses auto-rotation when a user clicks a dot
  • Fades and slides the quote text on transition
  • Shows the reviewer name and role below each quote
  • Works with keyboard focus (buttons are focusable)
  • Requires zero JavaScript imports beyond Alpine itself

Setting up: Tailwind v4 and Alpine

Tailwind v4 dropped tailwind.config.js entirely. Configuration now lives in your CSS file using the @theme directive. If you are on Astro 7, add the Tailwind v4 plugin to your project and import it in your global stylesheet:

/* src/styles/global.css */
@import "tailwindcss";

@theme {
  --color-brand: oklch(52% 0.22 250);
  --color-brand-light: oklch(68% 0.18 250);
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --radius-card: 1rem;
}

The @theme block generates both CSS custom properties on :root and the corresponding utility classes — so bg-brand, text-brand, and rounded-card all just work. You do not need to add anything to astro.config.mjs beyond what the Tailwind v4 Astro integration already wires up.

For Alpine, add the CDN defer script to your layout, or install the npm package and initialize it in your entry point:

<!-- CDN approach (fine for prototyping) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>

In a production Astro project, install alpinejs and initialize it in a client script so it tree-shakes correctly.

Registering the component with Alpine.data()

When an x-data object gets long, Alpine's Alpine.data() method lets you extract it into a named, reusable component. Register it before Alpine initializes — the alpine:init event fires at exactly the right moment:

<script>
document.addEventListener('alpine:init', () => {
  Alpine.data('testimonialCarousel', () => ({
    active: 0,
    autorotate: true,
    autorotateTiming: 7000,
    autorotateInterval: null,

    testimonials: [
      {
        quote: "They showed up on time, gave a fair quote, and finished in one day. Best contractor I've hired.",
        name: "Sarah M.",
        role: "Homeowner, Portland OR"
      },
      {
        quote: "Prompt, professional, and genuinely cared about doing the job right. Would hire again without hesitation.",
        name: "David K.",
        role: "Property Manager, Seattle WA"
      },
      {
        quote: "Compared three companies and chose them for the price and the reviews. Both delivered.",
        name: "Priya L.",
        role: "Small Business Owner, Beaverton OR"
      }
    ],

    init() {
      if (this.autorotate) {
        this.autorotateInterval = setInterval(() => {
          this.active = (this.active + 1) % this.testimonials.length;
        }, this.autorotateTiming);
      }
    },

    stopAutorotate() {
      clearInterval(this.autorotateInterval);
      this.autorotateInterval = null;
    },

    goTo(index) {
      this.active = index;
      this.stopAutorotate();
    }
  }))
})
</script>

A few things worth noting. active is the index of the currently visible slide. The init() lifecycle hook runs automatically when Alpine initializes the component — no need to call it yourself. The modulo operator (%) in the interval callback wraps the index back to zero when it reaches the end. goTo() is a single method that both changes the slide and stops auto-rotation, keeping the click handler in the template clean.

The HTML template

Now wire it up. The component root gets x-data="testimonialCarousel", and every interactive part reads from or writes to the state object above:

<section x-data="testimonialCarousel" class="py-16 px-4">
  <div class="max-w-2xl mx-auto text-center">

    <!-- Quote area -->
    <div class="relative min-h-36 mb-8">
      <template x-for="(testimonial, index) in testimonials" :key="index">
        <div
          x-show="active === index"
          x-transition:enter="transition ease-out duration-400"
          x-transition:enter-start="opacity-0 translate-y-2"
          x-transition:enter-end="opacity-100 translate-y-0"
          x-transition:leave="transition ease-in duration-200"
          x-transition:leave-start="opacity-100 translate-y-0"
          x-transition:leave-end="opacity-0 -translate-y-2"
          class="absolute inset-0"
        >
          <blockquote class="text-xl font-medium text-gray-800 leading-relaxed">
            &ldquo;<span x-text="testimonial.quote"></span>&rdquo;
          </blockquote>
          <footer class="mt-4">
            <p class="font-semibold text-gray-900" x-text="testimonial.name"></p>
            <p class="text-sm text-gray-500" x-text="testimonial.role"></p>
          </footer>
        </div>
      </template>
    </div>

    <!-- Dot navigation -->
    <div class="flex justify-center gap-2 mt-4" role="tablist" aria-label="Testimonials">
      <template x-for="(testimonial, index) in testimonials" :key="index">
        <button
          role="tab"
          :aria-selected="active === index"
          :aria-label="'Show testimonial from ' + testimonial.name"
          @click="goTo(index)"
          class="w-2.5 h-2.5 rounded-full transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
          :class="active === index
            ? 'bg-brand'
            : 'bg-gray-300 hover:bg-gray-400'"
        ></button>
      </template>
    </div>

  </div>
</section>

How the pieces fit together

x-for and x-show

x-for renders a <template> for each item in the array. Each iteration creates its own DOM node, so Alpine can apply independent x-show and x-transition directives to each slide. When active === index is true, Alpine shows that slide; when it is false, Alpine sets display: none — but only after the leave transition completes. The class="absolute inset-0" on each slide stacks them all in the same space so they overlap cleanly during transitions.

x-transition

Alpine's x-transition modifier shorthand lets you describe four states: enter (the transition itself), enter-start (the starting CSS), enter-end (the ending CSS), and the mirror pair for leave. Here the entering slide fades up from opacity-0 translate-y-2 to opacity-100 translate-y-0, while the leaving slide fades up and out in the opposite direction. The asymmetric duration (400ms in, 200ms out) makes the entrance feel deliberate and the exit feel snappy.

:class binding

The dot buttons use a ternary inside :class to toggle between an active style (bg-brand, which maps to your --color-brand theme variable) and a muted default. This pattern — one expression, two class strings — is idiomatic Alpine and avoids the need for a separate computed property.

Accessibility

The navigation uses role="tablist" and role="tab" with :aria-selected bound to the active state. Screen readers announce which testimonial is selected. The :aria-label binding includes the reviewer name so a screen reader user can skip directly to a specific person's quote. The focus-visible:outline classes ensure keyboard users see a clear focus indicator without affecting mouse users.

Putting it in an Astro component

In an Astro project, move the data array out of the inline script and into the component's frontmatter, then emit it as JSON into the Alpine registration. This keeps your content in one place and makes the Alpine object lean:

---
// src/components/TestimonialCarousel.astro
const testimonials = [
  {
    quote: "They showed up on time, gave a fair quote, and finished in one day.",
    name: "Sarah M.",
    role: "Homeowner, Portland OR"
  },
  {
    quote: "Prompt, professional, and genuinely cared about doing the job right.",
    name: "David K.",
    role: "Property Manager, Seattle WA"
  },
  {
    quote: "Compared three companies and chose them for the price and the reviews.",
    name: "Priya L.",
    role: "Small Business Owner, Beaverton OR"
  }
];
---

<section x-data="testimonialCarousel" class="py-16 px-4">
  <!-- ...same markup as above... -->
</section>

<script define:vars={{ testimonials }}>
document.addEventListener('alpine:init', () => {
  Alpine.data('testimonialCarousel', () => ({
    active: 0,
    autorotate: true,
    autorotateTiming: 7000,
    autorotateInterval: null,
    testimonials,

    init() {
      if (this.autorotate) {
        this.autorotateInterval = setInterval(() => {
          this.active = (this.active + 1) % this.testimonials.length;
        }, this.autorotateTiming);
      }
    },

    stopAutorotate() {
      clearInterval(this.autorotateInterval);
      this.autorotateInterval = null;
    },

    goTo(index) {
      this.active = index;
      this.stopAutorotate();
    }
  }))
})
</script>

Astro's define:vars directive serializes the frontmatter variable as JSON and injects it into the script's scope at build time. The result is a plain JavaScript object available in the browser — no extra fetch, no hydration framework.

Common pitfalls

The absolute positioning trap

Without position: absolute on each slide and position: relative; min-height: ... on the container, all three slides stack vertically and the container collapses once the enter and leave transitions overlap. Set a min-h-* on the container that accommodates your longest quote, or use a heightFix() approach that reads the active child's offsetHeight via this.$refs and sets it on the parent.

Alpine registering after initialization

If you call Alpine.data() after Alpine has already started (i.e., outside the alpine:init event), the component definition will not be found when the DOM is parsed. Always register inside the alpine:init listener, or register before Alpine's CDN script loads.

x-for without a :key

Omitting :key on x-for causes Alpine to reuse DOM nodes when the array changes, which breaks transition animations. Always provide a stable, unique key — the array index is fine when the array is static.

Extending the component

A few natural next steps: add previous and next buttons by exposing a prev() method that decrements with wraparound; add a progress bar that resets its CSS animation on each slide change using x-effect; or pull the testimonials from an Astro content collection so editors can add quotes without touching component code. The state object is a plain JavaScript object — extending it is straightforward, and nothing changes in the template structure.

The pattern here — a named Alpine.data() component, data separated from markup, transitions driven by x-show — scales to more complex carousels without reaching for a heavier library. For most local business testimonial sections, this is as much JavaScript as you need.