Summit Themes
Blog

How to build an Alpine.js testimonial carousel with right-edge controls

Testimonial sections feel obligatory — and they look it. A static grid of headshots is fine, but a rotating carousel keeps the page alive without JavaScript framework overhead. Alpine.js is a natural fit: you get reactive state and smooth transitions in roughly 20 lines of markup, with zero build step if you pull it from a CDN.

This tutorial walks through a working testimonial carousel. The distinguishing detail is the control placement: both the Previous and Next buttons live together at the right edge of the header row, rather than flanking the slide on the left and right. That layout keeps the content area uncluttered and puts navigation where the eye already drifts after reading a heading.

What you will build

A section with a heading row on the left and two small arrow buttons on the right. Below that, one testimonial is visible at a time with a fade + slight upward enter transition. Clicking Next advances the carousel; clicking Prev goes back. The index wraps around — going past the last slide loops to the first. The whole thing is a single HTML element with an x-data attribute; no external scripts beyond Alpine itself and Tailwind.

Prerequisites

  • Alpine.js 3 loaded — either via CDN (<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>) or as an npm import.
  • Tailwind CSS v4. The examples below use v4 utility classes. No tailwind.config.js is needed — configuration lives in your CSS via the @theme directive.
  • Basic familiarity with HTML and Alpine's x-data / x-on directives.

Step 1 — Define the component state

All Alpine components begin with x-data. The carousel needs three things: the array of testimonials, the index of the currently visible slide, and methods to move forward and backward.

x-data="{
  current: 0,
  testimonials: [
    {
      quote: 'Our energy bills dropped 30% in the first season. Exceptional work.',
      name: 'Maria T.',
      title: 'Homeowner, Austin TX'
    },
    {
      quote: 'Showed up on time, explained every step, and left the site spotless.',
      name: 'Derek R.',
      title: 'Property Manager'
    },
    {
      quote: 'Called at 8 pm on a Friday. They still picked up. Five stars.',
      name: 'Priya S.',
      title: 'Small Business Owner'
    }
  ],
  next() {
    this.current = (this.current + 1) % this.testimonials.length;
  },
  prev() {
    this.current = (this.current - 1 + this.testimonials.length) % this.testimonials.length;
  }
}"

The modulo trick in next() and prev() keeps the index in range without any if statements. Subtracting 1 and adding the array length before the modulo ensures the result is never negative — JavaScript's % operator can return negative values for negative operands.

Step 2 — Build the header row with right-edge controls

The layout goal is: section heading on the left, both arrow buttons grouped together on the right. Flexbox with justify-between and items-center handles that in one line.

<div class="flex items-center justify-between mb-8">
  <h2 class="text-2xl font-semibold tracking-tight">
    What our customers say
  </h2>

  <!-- Right-edge controls -->
  <div class="flex items-center gap-2">
    <button
      @click="prev()"
      aria-label="Previous testimonial"
      class="inline-flex items-center justify-center w-9 h-9 rounded-full border border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900 transition-colors"
    >
      <!-- Left chevron -->
      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
           viewBox="0 0 24 24" fill="none"
           stroke="currentColor" stroke-width="2"
           stroke-linecap="round" stroke-linejoin="round"
           aria-hidden="true">
        <polyline points="15 18 9 12 15 6"></polyline>
      </svg>
    </button>

    <button
      @click="next()"
      aria-label="Next testimonial"
      class="inline-flex items-center justify-center w-9 h-9 rounded-full border border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900 transition-colors"
    >
      <!-- Right chevron -->
      <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
           viewBox="0 0 24 24" fill="none"
           stroke="currentColor" stroke-width="2"
           stroke-linecap="round" stroke-linejoin="round"
           aria-hidden="true">
        <polyline points="9 18 15 12 9 6"></polyline>
      </svg>
    </button>
  </div>
</div>

The aria-label attributes give screen reader users a meaningful action name. The SVGs use aria-hidden="true" because the button label already describes the action — the icon is purely decorative in this context.

Step 3 — Render the slides with x-show and x-transition

Each testimonial gets its own block. Alpine's x-show toggles visibility based on whether its index matches current. Adding x-transition gives Alpine control over the enter/leave animation.

By default, x-transition applies a combined opacity and scale effect. You can customise it with modifiers like x-transition.duration.300ms, or break it into four granular attributes (x-transition:enter, x-transition:enter-start, x-transition:enter-end, x-transition:leave-end) if you want a directional slide. For a testimonial carousel a clean fade is less jarring, so the default works well.

<div class="relative min-h-40">
  <template x-for="(item, index) in testimonials" :key="index">
    <div
      x-show="current === index"
      x-transition.duration.400ms
      class="absolute inset-0"
    >
      <blockquote>
        <p class="text-lg text-neutral-700 leading-relaxed mb-6"
           x-text="'&ldquo;' + item.quote + '&rdquo;'"></p>
        <footer class="flex items-center gap-3">
          <div class="w-9 h-9 rounded-full bg-neutral-200 flex-shrink-0"></div>
          <div>
            <p class="font-semibold text-neutral-900 text-sm" x-text="item.name"></p>
            <p class="text-neutral-500 text-xs" x-text="item.title"></p>
          </div>
        </footer>
      </blockquote>
    </div>
  </template>
</div>

A few details worth noting. The wrapping div uses relative and min-h-40 while each slide is absolute inset-0. This is the standard trick for stacking elements on top of each other so that enter and leave transitions can overlap cleanly — without it, the leaving slide pushes down the entering one. Set min-h-40 (or a fixed height) to match your longest testimonial, or calculate it in JavaScript if content varies significantly.

The x-text on the quote uses JavaScript string concatenation to wrap the quote in curly typographic marks. You could also put those characters directly in a <span> outside of x-text if you prefer to keep the template readable.

Step 4 — Putting it all together

Here is the complete component. Drop it into any Astro component, a plain HTML file, or a Blade/Twig template — anywhere Alpine is loaded.

<section
  x-data="{
    current: 0,
    testimonials: [
      {
        quote: 'Our energy bills dropped 30% in the first season. Exceptional work.',
        name: 'Maria T.',
        title: 'Homeowner, Austin TX'
      },
      {
        quote: 'Showed up on time, explained every step, and left the site spotless.',
        name: 'Derek R.',
        title: 'Property Manager'
      },
      {
        quote: 'Called at 8 pm on a Friday. They still picked up. Five stars.',
        name: 'Priya S.',
        title: 'Small Business Owner'
      }
    ],
    next() {
      this.current = (this.current + 1) % this.testimonials.length;
    },
    prev() {
      this.current = (this.current - 1 + this.testimonials.length) % this.testimonials.length;
    }
  }"
  class="max-w-2xl mx-auto py-16 px-6"
>
  <!-- Header row: heading left, controls right -->
  <div class="flex items-center justify-between mb-8">
    <h2 class="text-2xl font-semibold tracking-tight">
      What our customers say
    </h2>

    <div class="flex items-center gap-2">
      <button
        @click="prev()"
        aria-label="Previous testimonial"
        class="inline-flex items-center justify-center w-9 h-9 rounded-full border border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900 transition-colors"
      >
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
             viewBox="0 0 24 24" fill="none"
             stroke="currentColor" stroke-width="2"
             stroke-linecap="round" stroke-linejoin="round"
             aria-hidden="true">
          <polyline points="15 18 9 12 15 6"></polyline>
        </svg>
      </button>

      <button
        @click="next()"
        aria-label="Next testimonial"
        class="inline-flex items-center justify-center w-9 h-9 rounded-full border border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900 transition-colors"
      >
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
             viewBox="0 0 24 24" fill="none"
             stroke="currentColor" stroke-width="2"
             stroke-linecap="round" stroke-linejoin="round"
             aria-hidden="true">
          <polyline points="9 18 15 12 9 6"></polyline>
        </svg>
      </button>
    </div>
  </div>

  <!-- Slides -->
  <div class="relative min-h-40">
    <template x-for="(item, index) in testimonials" :key="index">
      <div
        x-show="current === index"
        x-transition.duration.400ms
        class="absolute inset-0"
      >
        <blockquote>
          <p class="text-lg text-neutral-700 leading-relaxed mb-6"
             x-text="'&ldquo;' + item.quote + '&rdquo;'"></p>
          <footer class="flex items-center gap-3">
            <div class="w-9 h-9 rounded-full bg-neutral-200 flex-shrink-0"></div>
            <div>
              <p class="font-semibold text-neutral-900 text-sm" x-text="item.name"></p>
              <p class="text-neutral-500 text-xs" x-text="item.title"></p>
            </div>
          </footer>
        </blockquote>
      </div>
    </template>
  </div>

  <!-- Optional: dot indicators -->
  <div class="flex items-center gap-1.5 mt-10">
    <template x-for="(item, index) in testimonials" :key="index">
      <button
        @click="current = index"
        :aria-label="'Go to testimonial ' + (index + 1)"
        :class="current === index
          ? 'w-4 h-1.5 bg-neutral-700 rounded-full'
          : 'w-1.5 h-1.5 bg-neutral-300 rounded-full hover:bg-neutral-400'"
        class="transition-all duration-300"
      ></button>
    </template>
  </div>
</section>

Optional additions

Auto-advance

If you want the carousel to advance on its own, add an init() method to the x-data object. Alpine calls init() automatically when the component mounts.

init() {
  setInterval(() => this.next(), 5000);
}

Auto-advance should pause on hover or focus. Alpine makes that straightforward with @mouseenter and @mouseleave events — track a paused boolean in state and check it inside the interval callback, or use clearInterval/setInterval to stop and restart.

Keyboard navigation

The arrow buttons are focusable by default, so Tab + Enter or Space already work. For full keyboard carousel behavior, add @keydown.arrow-right="next()" and @keydown.arrow-left="prev()" on the section element itself, then set tabindex="0" on it so it can receive key events.

Tailwind v4 theming

If you want the button ring color to match your brand, define it once in your CSS entry point rather than repeating a hex value across components:

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.52 0.19 256);
}

Then reference it as border-brand and hover:border-brand in your button classes. The @theme block is all you need — no tailwind.config.js in v4.

Extracting testimonials from a data file in Astro

Hardcoding testimonials inside x-data works for small sites. In an Astro project you typically want to keep copy in a data file so a content update does not require touching component markup. Move the array to src/data/testimonials.json, import it in your .astro file, and pass it to Alpine via a serialized attribute:

---
import testimonials from '../data/testimonials.json';
---

<section
  x-data={`{
    current: 0,
    testimonials: ${JSON.stringify(testimonials)},
    next() { this.current = (this.current + 1) % this.testimonials.length; },
    prev() { this.current = (this.current - 1 + this.testimonials.length) % this.testimonials.length; }
  }`}
>
  <!-- same markup as above -->
</section>

JSON.stringify produces valid JavaScript object literal syntax, so Alpine parses the inlined string safely. Astro renders this at build time — no runtime fetch required.

Closing thoughts

The right-edge control layout is a small decision with a noticeable effect: visitors read the quote without arrows crowding the sides, and the grouping of Prev + Next signals they belong to the heading row rather than the content. Combined with Alpine's lightweight footprint — no virtual DOM, no bundler required — this pattern slots cleanly into any Astro theme without adding weight. The whole component hydrates in under a millisecond and degrades gracefully to showing the first testimonial if JavaScript fails.