Summit Themes
Blog

How to build a Tailwind CSS + Alpine.js testimonial carousel (snap scroll)

Testimonial carousels are one of those components that look simple but tend to attract plugin debt. You reach for a library, it drags in jQuery or a 40 kB bundle, and now you have a dependency to maintain forever just to show three quotes from happy customers. There is a lighter path: CSS scroll snap handles the physics, Alpine.js owns a handful of lines of state, and Tailwind CSS v4 utilities keep the markup tidy. The result is smooth on mouse, trackpad, and touch — and the whole thing weighs almost nothing.

This guide builds the component from scratch. By the end you will have a fully working, accessible testimonial slider you can drop into any Astro (or plain HTML) project.

How the pieces fit together

Before touching code, it is worth being clear about what each layer does:

  • CSS scroll snap — the browser handles the actual snap physics. No JavaScript needed for "settling." You get native smooth scrolling on trackpads and inertia on touch screens for free.
  • Alpine.js — owns the Prev/Next button state and programmatic scrolling. When a user clicks a button, Alpine reads the card width, adds the CSS gap, and calls scrollTo() with behavior: 'smooth'. That is the entirety of the JavaScript.
  • Tailwind CSS v4 — provides the snap-x, snap-mandatory, snap-start, and overflow utilities. Because v4 is CSS-first, you do not need a tailwind.config.js at all for these built-in utilities.

Step 1: Project setup

For an Astro project, install Tailwind v4 and Alpine.js. The Tailwind v4 Vite plugin handles everything automatically:

npm install tailwindcss @tailwindcss/vite alpinejs

In your Astro config, add the Vite plugin:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  vite: {
    plugins: [tailwindcss()],
  },
});

In your global CSS file, import Tailwind — that is all v4 needs:

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

For Alpine.js, initialize it in your base layout or a client-side script tag:

<script>
  import Alpine from 'alpinejs';
  window.Alpine = Alpine;
  Alpine.start();
</script>

If you are using a plain HTML file, load Alpine from a CDN instead:

<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

Step 2: The testimonial data

Keep testimonial data out of your markup. In Astro, a simple array in the component frontmatter works fine. For a real project you would pull this from a content collection or a CMS:

---
// TestimonialCarousel.astro
const testimonials = [
  {
    quote: "We booked three new jobs in the first week after launch. The site looks better than anything we could have built ourselves.",
    author: "Maria Gonzalez",
    role: "Owner, Gonzalez HVAC",
  },
  {
    quote: "Customers keep telling us our website is the best they have seen from a local contractor. That was never true before.",
    author: "Derek Ashton",
    role: "Owner, Ashton Electrical",
  },
  {
    quote: "The SEO pages were already written for our service area. Saved us months of copywriting.",
    author: "Priya Nair",
    role: "Co-owner, Nair Plumbing",
  },
  {
    quote: "Setup took one afternoon. We had the demo customized and live by the end of the day.",
    author: "Tom Wicker",
    role: "Owner, Ridgeline Roofing",
  },
];
---

Step 3: The scroll container and cards

The outer container needs three things from Tailwind: snap-x (enable horizontal snap), snap-mandatory (snap on every stop, not just near stops), and overflow-x-auto (allow scrolling). Add scroll-smooth to make programmatic scrolls animate. A negative margin plus matching padding trick (-mx-4 px-4 or similar) lets cards appear edge-to-edge on mobile without clipping the scroll snap:

<div
  x-ref="slider"
  class="flex snap-x snap-mandatory overflow-x-auto scroll-smooth gap-6 pb-4"
  style="scrollbar-width: none;"
>
  <!-- cards go here -->
</div>

The scrollbar-width: none inline style hides the scrollbar on Firefox. For Webkit browsers, add this to your CSS:

.testimonial-slider::-webkit-scrollbar {
  display: none;
}

Each card needs snap-start and a fixed width. Using min-w-full creates a full-width one-at-a-time carousel. Using min-w-80 or a percentage shows multiple cards at once on wider screens:

<!-- Full-width card (one at a time) -->
<article class="snap-start min-w-full flex-shrink-0 bg-white rounded-2xl p-8 shadow-sm">
  <!-- content -->
</article>

Using snap-center instead of snap-start works well for multi-card layouts where you want the active card centered in the viewport.

Step 4: Alpine.js state and navigation logic

The x-data attribute on the wrapper element defines the component's state and behavior. The key insight is that you calculate the scroll distance from the actual rendered DOM — card width plus CSS gap — rather than hardcoding it. This means the layout can change responsively and the buttons still work correctly:

x-data="{
  get slider() { return this.$refs.slider },
  scrollStep() {
    const card = this.slider.firstElementChild;
    const gap = parseInt(getComputedStyle(this.slider).columnGap) || 0;
    return card.offsetWidth + gap;
  },
  next() {
    const max = this.slider.scrollWidth - this.slider.clientWidth;
    const target = Math.min(this.slider.scrollLeft + this.scrollStep(), max);
    this.slider.scrollTo({ left: target, behavior: 'smooth' });
  },
  prev() {
    const target = Math.max(this.slider.scrollLeft - this.scrollStep(), 0);
    this.slider.scrollTo({ left: target, behavior: 'smooth' });
  }
}"

The $refs.slider magic property gives Alpine direct access to the DOM element marked with x-ref="slider". The scrollStep() getter uses getComputedStyle to read the actual column-gap value so the scroll offset matches the visual gap exactly.

Step 5: The complete component

Putting it all together, here is the full TestimonialCarousel.astro component:

---
const testimonials = [
  {
    quote: "We booked three new jobs in the first week after launch. The site looks better than anything we could have built ourselves.",
    author: "Maria Gonzalez",
    role: "Owner, Gonzalez HVAC",
  },
  {
    quote: "Customers keep telling us our website is the best they have seen from a local contractor. That was never true before.",
    author: "Derek Ashton",
    role: "Owner, Ashton Electrical",
  },
  {
    quote: "The SEO pages were already written for our service area. Saved us months of copywriting.",
    author: "Priya Nair",
    role: "Co-owner, Nair Plumbing",
  },
  {
    quote: "Setup took one afternoon. We had the demo customized and live by the end of the day.",
    author: "Tom Wicker",
    role: "Owner, Ridgeline Roofing",
  },
];
---

<section
  aria-label="Customer testimonials"
  x-data="{
    get slider() { return this.$refs.slider },
    scrollStep() {
      const card = this.slider.firstElementChild;
      const gap = parseInt(getComputedStyle(this.slider).columnGap) || 0;
      return card.offsetWidth + gap;
    },
    next() {
      const max = this.slider.scrollWidth - this.slider.clientWidth;
      const target = Math.min(this.slider.scrollLeft + this.scrollStep(), max);
      this.slider.scrollTo({ left: target, behavior: 'smooth' });
    },
    prev() {
      const target = Math.max(this.slider.scrollLeft - this.scrollStep(), 0);
      this.slider.scrollTo({ left: target, behavior: 'smooth' });
    }
  }"
  @keydown.left="prev()"
  @keydown.right="next()"
  tabindex="0"
  class="relative w-full max-w-4xl mx-auto px-4 py-16"
>
  <h2 class="text-3xl font-bold text-center mb-10">What our customers say</h2>

  <div
    x-ref="slider"
    class="flex snap-x snap-mandatory overflow-x-auto scroll-smooth gap-6 pb-4"
    style="scrollbar-width: none;"
  >
    {testimonials.map((t) => (
      <article class="snap-start min-w-full flex-shrink-0 bg-white rounded-2xl p-8 shadow-sm border border-gray-100">
        <blockquote class="text-lg text-gray-700 leading-relaxed mb-6">
          &ldquo;{t.quote}&rdquo;
        </blockquote>
        <footer class="flex items-center gap-3">
          <div class="size-10 rounded-full bg-gray-200 flex-shrink-0"></div>
          <div>
            <p class="font-semibold text-gray-900">{t.author}</p>
            <p class="text-sm text-gray-500">{t.role}</p>
          </div>
        </footer>
      </article>
    ))}
  </div>

  <div class="flex items-center justify-center gap-4 mt-6">
    <button
      @click="prev()"
      aria-label="Previous testimonial"
      class="size-10 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-50 transition-colors"
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
        <path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
      </svg>
    </button>
    <button
      @click="next()"
      aria-label="Next testimonial"
      class="size-10 rounded-full border border-gray-300 flex items-center justify-center hover:bg-gray-50 transition-colors"
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
        <path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
      </svg>
    </button>
  </div>
</section>

Accessibility considerations

A few things in the markup above are worth calling out explicitly:

  • aria-label="Customer testimonials" on the section gives screen readers a landmark label without relying on a visible heading nearby.
  • tabindex="0" on the section, combined with @keydown.left and @keydown.right, lets keyboard users navigate the carousel with arrow keys after focusing the element. Alpine's @keydown shorthand handles this cleanly.
  • Each card uses <article> (a self-contained piece of content) with a <blockquote> and <footer> — semantically correct for a testimonial.
  • The Prev/Next buttons have explicit aria-label attributes since they contain only icons.

If you want to add dot indicators, Alpine makes it easy. Add a current property to your x-data, update it in next() and prev(), and bind button states with :class.

Showing multiple cards on wider screens

The full-width card approach is the simplest, but showing two or three cards at once on desktop is a common request. Change min-w-full to a fixed width and add a responsive override:

<article class="snap-start min-w-full md:min-w-[calc(50%-0.75rem)] lg:min-w-[calc(33.333%-1rem)] flex-shrink-0 ...">

The calc values subtract half the gap-6 (1.5rem) so the cards fit within the container without overflow. At that point you may also want to switch from snap-start to snap-center so the active card centers itself in the viewport on touch scroll.

The scrollStep() function still works correctly at any card size because it reads from the DOM at call time, not from a hardcoded value.

A note on scroll-snap-stop

By default, a fast swipe on a touch screen can skip past multiple snap points. If you want to force the carousel to stop at every card, even during a fast swipe, add the scroll-snap-stop: always property to each card. Tailwind v4 includes this as a utility:

<article class="snap-start snap-always ...">

Use this carefully — it can feel sluggish on touch when there are many cards. For a testimonial slider with three or four items it is usually fine.

Wrapping up

The finished component is about 60 lines of markup, a dozen lines of Alpine state, and zero external carousel dependencies. CSS scroll snap does the heavy lifting: snapping, inertia, touch support — all handled natively. Alpine provides the thin wrapper that makes Prev/Next buttons work predictably. Tailwind v4 utilities (snap-x, snap-mandatory, snap-start, overflow-x-auto) wire the CSS behavior without writing a single custom style.

This is a pattern worth knowing because the same structure applies beyond testimonials — image galleries, feature comparison cards, pricing tiers. Once you understand how scroll snap and programmatic scrollTo interact, you can build any horizontal carousel this way.