Black Weeks . Full Access for 50% OFF. Use code lex50 at checkout.

You'll get every theme available plus future additions. That's 40 themes total.Unlimited projects. Lifetime updates. One payment.

Get full access

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

Create a responsive testimonial carousel using Tailwind CSS and Alpine.js with snap scrolling, keyboard navigation, and smooth Prev/Next controls. Includes component-level snippets and a full example.

Published on November 14, 2025 by Michael Andreuzza

Build a responsive testimonial carousel using Tailwind CSS for layout/spacing and Alpine.js for simple state and controls. This version uses horizontal snap scrolling so it’s smooth on trackpads and touch, while the Prev/Next buttons provide precise navigation.

Include Alpine.js

If Alpine.js isn’t already loaded globally, add it via CDN:

html
<script src="https://unpkg.com/alpinejs" defer></script>

Optional: If you want to react to items entering/leaving the viewport with x-intersect:* (e.g., to adjust focusability), also include the Intersect plugin:

html
<script src="https://unpkg.com/@alpinejs/intersect" defer></script>

Alpine.js state and methods

We’ll navigate by the width of a single card plus its CSS column gap. skip defines how many cards to advance per click.

html
<section
  x-data="{
    skip: 1,
    next() { this.to((current, offset, slider) => {
      const target = current + (offset * this.skip)
      const max = slider.scrollWidth - slider.clientWidth
      if (target >= max - 1) return 0
      return target
    })},
    prev() { this.to((current, offset, slider) => {
      const target = current - (offset * this.skip)
      if (target <= 1) return slider.scrollWidth - slider.clientWidth
      return 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' })
    },
  }"
>
  <!-- controls + slider will go here -->
</section>

Controls UI (Prev/Next)

Accessible buttons with clear labels. Tailwind classes add outline and hover states.

html
<div class="inline-flex items-center w-full mt-4 space-x-2">
  <button
    class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
    x-on:click="prev"
    aria-label="Previous testimonials"
  >
    <!-- left chevron -->
    <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="icon icon-tabler-chevron-left size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 6l-6 6l6 6"></path></svg>
  </button>
  <button
    class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
    x-on:click="next"
    aria-label="Next testimonials"
  >
    <!-- right chevron -->
    <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="icon icon-tabler-chevron-right size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 6l6 6l-6 6"></path></svg>
  </button>
  <!-- Optional: change `skip` on the fly -->
  <!-- <button @click="skip = 2" class="text-xs underline">Jump 2 cards</button> -->
</div>

Slider container (snap scrolling)

Use horizontal overflow with snap alignment so cards settle predictably after touch/trackpad scrolls. x-ref="slider" gives methods access to the element.

html
<ul
  class="relative flex w-full mt-4 overflow-x-scroll gap-3 scrollbar-hide snap-x snap-mandatory"
  role="listbox"
  aria-label="Testimonials"
  tabindex="0"
  x-ref="slider"
>
  <!-- slides -->
</ul>

Slide card (testimonial)

Each slide snaps into place with snap-start and doesn’t shrink. The figure stacks content on small screens and aligns side-by-side on large.

html
<li class="w-full shrink-0 snap-start" role="option">
  <figure class="relative flex flex-col items-center lg:flex-row gap-12">
    <div>
      <blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
        "Oxbow's components let us move from concept to production in days, not months."
      </blockquote>
      <div class="mt-6">
        <h3 class="text-base font-semibold text-zinc-900">Ana Betancourt</h3>
        <p class="text-sm font-medium text-zinc-600">Creator of Something</p>
      </div>
    </div>
    <figcaption class="justify-between w-full">
      <img
        alt="Portrait of Ana Betancourt"
        src="https://oxbowui.com/avatars/5.jpg"
        class="object-cover size-full rounded-2xl aspect-[4/3]"
      />
    </figcaption>
  </figure>
 </li>

Keyboard navigation and region semantics

Wrap the slider and controls in a labeled region to improve keyboard discovery. Arrow keys can call prev/next.

html
<div
  class="relative flex flex-col w-full"
  role="region"
  aria-labelledby="carousel-label"
  tabindex="0"
  x-on:keydown.left="prev"
  x-on:keydown.right="next"
>
  <h2 id="carousel-label" class="sr-only">Testimonials Carousel</h2>
  <!-- controls + slider here -->
</div>

Tuning card widths and jumps

  • Show more cards: add fixed widths like sm:w-1/2 lg:w-1/3 to <li> or migrate to a grid track: grid grid-flow-col auto-cols-[80%] sm:auto-cols-[50%] lg:auto-cols-[33%].
  • Jump multiple cards per click: set skip = 2 (or more) in state or toggle it via a button.
  • Adjust gaps: modify the gap-* class; the JavaScript reads the computed column-gap to calculate offsets.

Full copy‑paste example

All image URLs are absolute (prefixed with https://oxbowui.com/) so they don’t break. Replace them with your own when integrating.

html
<div class="flex flex-col w-full"
  x-data="{
    skip: 1,
    next() {
      this.to((current, offset, slider) => {
        const target = current + (offset * this.skip)
        const max = slider.scrollWidth - slider.clientWidth
        if (target >= max - 1) return 0
        return target
      })
    },
    prev() {
      this.to((current, offset, slider) => {
        const target = current - (offset * this.skip)
        if (target <= 1) return slider.scrollWidth - slider.clientWidth
        return 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' })
    },
  }"
>
  <div
    class="relative flex flex-col w-full"
    aria-labelledby="carousel-label"
    role="region"
    tabindex="0"
    x-on:keydown.left="prev"
    x-on:keydown.right="next"
  >
    <h2 class="sr-only" id="carousel-label">Carousel</h2>
    <span class="sr-only" id="carousel-content-label">Testimonials</span>

    <div class="inline-flex items-center order-last w-full mt-4 space-x-2">
      <button
        class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
        x-on:click="prev"
        aria-label="Previous testimonials"
      >
        <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="icon icon-tabler-chevron-left size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 6l-6 6l6 6"></path></svg>
      </button>
      <button
        class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-zinc-600 bg-zinc-50 outline outline-zinc-50 hover:bg-zinc-200 focus:outline-zinc-600 size-9 p-2 text-sm rounded-md"
        x-on:click="next"
        aria-label="Next testimonials"
      >
        <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="icon icon-tabler-chevron-right size-5"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 6l6 6l-6 6"></path></svg>
      </button>
    </div>

    <ul
      class="relative flex w-full mt-4 overflow-x-scroll gap-3 scrollbar-hide snap-mandatory snap-x"
      role="listbox"
      aria-labelledby="carousel-content-label"
      tabindex="0"
      x-ref="slider"
    >
      <li class="w-full shrink-0 snap-start" role="option">
        <figure class="relative flex flex-col items-center lg:flex-row gap-12">
          <div>
            <blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
              Oxbow's components let us move from concept to production in days, not months.
            </blockquote>
            <div class="mt-6">
              <h3 class="text-base font-semibold text-zinc-900">Ana Betancourt</h3>
              <p class="text-sm font-medium text-zinc-600">Creator of Something</p>
            </div>
          </div>
          <figcaption class="justify-between w-full">
            <img alt="Portrait of Ana" src="https://oxbowui.com/avatars/5.jpg" class="object-cover size-full rounded-2xl aspect-[4/3]" />
          </figcaption>
        </figure>
      </li>
      <li class="w-full shrink-0 snap-start" role="option">
        <figure class="relative flex flex-col items-center lg:flex-row gap-12">
          <div>
            <blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
              Adopting the toolkit reduced our UI debt and improved consistency across products.
            </blockquote>
            <div class="mt-6">
              <h3 class="text-base font-semibold text-zinc-900">Juanjo Garcia</h3>
              <p class="text-sm font-medium text-zinc-600">Founder</p>
            </div>
          </div>
          <figcaption class="justify-between w-full">
            <img alt="Portrait of Juanjo" src="https://oxbowui.com/avatars/2.jpg" class="object-cover size-full rounded-2xl aspect-[4/3]" />
          </figcaption>
        </figure>
      </li>
      <li class="w-full shrink-0 snap-start" role="option">
        <figure class="relative flex flex-col items-center lg:flex-row gap-12">
          <div>
            <blockquote class="text-xl md:text-2xl lg:text-3xl italic font-medium text-zinc-900">
              The docs and defaults are excellent — our team onboarded in a single sprint.
            </blockquote>
            <div class="mt-6">
              <h3 class="text-base font-semibold text-zinc-900">Maya Lin</h3>
              <p class="text-sm font-medium text-zinc-600">Product Lead</p>
            </div>
          </div>
          <figcaption class="justify-between w-full">
            <img alt="Portrait of Maya" src="https://oxbowui.com/avatars/3.jpg" class="object-cover size-full rounded-2xl aspect-[4/3]" />
          </figcaption>
        </figure>
      </li>
    </ul>
  </div>
</div>

That’s it. You now have a lightweight, responsive testimonial carousel powered by Alpine.js and styled with Tailwind CSS. Adjust skip, gaps, and card widths to tune the interaction for your content.

/Michael Andreuzza

Did you like this post? Please share it with your friends!