Vue componentFree

Testimonial Carousel

Accessible Vue 3 testimonial carousel with prev/next, dots and autoplay that pauses on hover or focus and honours reduced motion. Tailwind CSS.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

One quote at a time, large and centred, for pages where a single strong testimonial says more than a wall of them. Visitors move with the previous and next buttons, the dots, or the arrow keys once the carousel has focus.

Autoplay is polite by default: it pauses while the pointer is over the carousel or anything inside it has focus, there is a visible Pause button, and it never starts when the visitor prefers reduced motion. While paused the slide region is a polite live region, so manual changes are announced; while playing it stays quiet.

All slides share one grid cell, so the card always has the height of the longest quote and the controls never jump. Set autoplay to false for a manual slider, change interval to adjust timing, and listen to change for analytics.

  • Prev/next buttons, dots and arrow keys
  • Autoplay pauses on hover and focus
  • Pause button and reduced-motion support
  • No layout shift between slides
  • Light and dark mode

Code

11 files in the repo
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useId, watchEffect } from 'vue'

export interface CarouselTestimonial {
  quote: string
  name: string
  role: string
  company: string
}

const props = withDefaults(defineProps<{
  testimonials?: CarouselTestimonial[]
  heading?: string
  /** Advance automatically. Pauses on hover and focus, and is off when the user prefers reduced motion. */
  autoplay?: boolean
  /** Milliseconds each testimonial stays on screen while autoplaying. */
  interval?: number
  startIndex?: number
}>(), {
  heading: 'What teams say after switching',
  autoplay: true,
  interval: 6000,
  startIndex: 0,
  testimonials: () => [
    { quote: 'We cut our release checklist from forty manual steps to six. Deploys used to eat a whole afternoon; now they happen between meetings and nobody holds their breath.', name: 'Ingrid Haugen', role: 'Platform Lead', company: 'Mossbank' },
    { quote: 'The preview environments changed how we review work. Designers click a link, leave a comment, and we fix it before it ever reaches staging.', name: 'Dev Raman', role: 'Frontend Lead', company: 'Parcelwise' },
    { quote: 'Our on-call rotation got quieter within a month. The rollback button alone has saved us more weekends than I would like to admit.', name: 'Clara Mbeki', role: 'SRE Manager', company: 'Driftline' },
    { quote: 'I expected a migration project. It took one sprint, and the team asked why we had not done it sooner.', name: 'Oliver Strand', role: 'CTO', company: 'Kestrel Maps' },
  ],
})

const emit = defineEmits<{
  change: [index: number]
}>()

const id = useId()
const count = computed(() => props.testimonials.length)
const current = ref(Math.min(Math.max(props.startIndex, 0), Math.max(count.value - 1, 0)))

const hovered = ref(false)
const focused = ref(false)
const userPaused = ref(false)
const reducedMotion = ref(false)

const canAutoplay = computed(() => props.autoplay && !reducedMotion.value && count.value > 1)
const running = computed(() => canAutoplay.value && !userPaused.value && !hovered.value && !focused.value)

function go(index: number) {
  if (!count.value) return
  current.value = (index + count.value) % count.value
  emit('change', current.value)
}
const next = () => go(current.value + 1)
const prev = () => go(current.value - 1)

let timer: ReturnType<typeof setInterval> | undefined
watchEffect((onCleanup) => {
  if (!running.value) return
  timer = setInterval(next, props.interval)
  onCleanup(() => clearInterval(timer))
})

let mq: MediaQueryList | undefined
const onMotion = (e: MediaQueryListEvent) => { reducedMotion.value = e.matches }
onMounted(() => {
  mq = window.matchMedia('(prefers-reduced-motion: reduce)')
  reducedMotion.value = mq.matches
  mq.addEventListener('change', onMotion)
})
onBeforeUnmount(() => {
  mq?.removeEventListener('change', onMotion)
  clearInterval(timer)
})

function onFocusOut(e: FocusEvent) {
  const root = e.currentTarget as HTMLElement
  if (!root.contains(e.relatedTarget as Node | null)) focused.value = false
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowRight') { e.preventDefault(); next() }
  else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
}

const initials = (name: string) => name.split(/\s+/).filter(Boolean).slice(0, 2).map(p => p[0]!.toUpperCase()).join('')
const focusRing = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600 dark:focus-visible:outline-teal-400'
</script>

<template>
  <section
    class="px-4 py-20 sm:px-6 sm:py-24"
    aria-roledescription="carousel"
    :aria-labelledby="`${id}-heading`"
    @mouseenter="hovered = true"
    @mouseleave="hovered = false"
    @focusin="focused = true"
    @focusout="onFocusOut"
    @keydown="onKeydown"
  >
    <div class="mx-auto max-w-3xl">
      <h2 :id="`${id}-heading`" class="text-center text-sm font-semibold tracking-wide text-teal-700 uppercase dark:text-teal-400">{{ heading }}</h2>

      <div class="relative mt-8 overflow-hidden rounded-3xl bg-white px-6 pt-18 pb-10 shadow-sm ring-1 ring-gray-200 sm:px-14 dark:bg-gray-900 dark:shadow-none dark:ring-white/10">
        <svg viewBox="0 0 48 48" aria-hidden="true" class="absolute top-6 left-1/2 size-10 -translate-x-1/2 fill-teal-500/80 dark:fill-teal-400/70">
          <path d="M10 36c-3.3 0-6-2.7-6-7.3C4 19 10 12.5 18 10l1.5 3.2c-4.8 2-7.6 5.6-8 9.3.6-.2 1.2-.3 1.8-.3 3.4 0 6.2 2.6 6.2 6.9S16.4 36 10 36Zm22 0c-3.3 0-6-2.7-6-7.3C26 19 32 12.5 40 10l1.5 3.2c-4.8 2-7.6 5.6-8 9.3.6-.2 1.2-.3 1.8-.3 3.4 0 6.2 2.6 6.2 6.9S38.4 36 32 36Z" />
        </svg>

        <!-- All slides share one grid cell so the card keeps the height of the tallest quote. -->
        <div class="relative grid" :aria-live="running ? 'off' : 'polite'">
          <figure
            v-for="(t, i) in testimonials"
            :key="t.name"
            :id="`${id}-slide-${i}`"
            role="group"
            aria-roledescription="slide"
            :aria-label="`${i + 1} of ${count}`"
            class="col-start-1 row-start-1 flex flex-col items-center text-center transition duration-500 ease-out motion-reduce:transition-none"
            :class="i === current ? 'visible translate-x-0 opacity-100' : 'invisible opacity-0 ' + (i < current ? '-translate-x-6' : 'translate-x-6')"
            :aria-hidden="i !== current"
          >
            <blockquote class="text-xl/8 font-medium text-pretty text-gray-900 sm:text-2xl/10 dark:text-white">
              <p>&ldquo;{{ t.quote }}&rdquo;</p>
            </blockquote>
            <figcaption class="mt-8 flex items-center gap-3">
              <span aria-hidden="true" class="grid size-11 place-items-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-sm font-semibold text-white">{{ initials(t.name) }}</span>
              <span class="text-left">
                <span class="block font-semibold text-gray-900 dark:text-white">{{ t.name }}</span>
                <span class="block text-sm text-gray-600 dark:text-gray-400">{{ t.role }}, {{ t.company }}</span>
              </span>
            </figcaption>
          </figure>
        </div>
      </div>

      <div v-if="count > 1" class="mt-6 flex items-center justify-between gap-4">
        <div class="flex items-center gap-2">
          <button
            type="button"
            class="grid size-10 place-items-center rounded-full bg-white text-gray-700 ring-1 ring-gray-200 transition hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-900 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-gray-800 dark:hover:text-white"
            :class="focusRing"
            :aria-controls="`${id}-slide-${current}`"
            aria-label="Previous testimonial"
            @click="prev"
          >
            <svg viewBox="0 0 20 20" aria-hidden="true" class="size-5 fill-current"><path fill-rule="evenodd" d="M12.8 4.2a.75.75 0 0 1 0 1.06L8.06 10l4.74 4.74a.75.75 0 1 1-1.06 1.06l-5.27-5.27a.75.75 0 0 1 0-1.06l5.27-5.27a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
          </button>
          <button
            type="button"
            class="grid size-10 place-items-center rounded-full bg-white text-gray-700 ring-1 ring-gray-200 transition hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-900 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-gray-800 dark:hover:text-white"
            :class="focusRing"
            :aria-controls="`${id}-slide-${current}`"
            aria-label="Next testimonial"
            @click="next"
          >
            <svg viewBox="0 0 20 20" aria-hidden="true" class="size-5 fill-current"><path fill-rule="evenodd" d="M7.2 15.8a.75.75 0 0 1 0-1.06L11.94 10 7.2 5.26A.75.75 0 1 1 8.26 4.2l5.27 5.27a.75.75 0 0 1 0 1.06L8.26 15.8a.75.75 0 0 1-1.06 0Z" clip-rule="evenodd" /></svg>
          </button>
        </div>

        <div class="flex items-center gap-1" role="group" aria-label="Choose testimonial">
          <button
            v-for="(t, i) in testimonials"
            :key="`dot-${t.name}`"
            type="button"
            class="group grid size-6 place-items-center rounded-full"
            :class="focusRing"
            :aria-label="`Show testimonial ${i + 1}: ${t.name}`"
            :aria-current="i === current ? 'true' : undefined"
            @click="go(i)"
          >
            <span
              class="block h-2 rounded-full transition-all motion-reduce:transition-none"
              :class="i === current ? 'w-6 bg-teal-600 dark:bg-teal-400' : 'w-2 bg-gray-300 group-hover:bg-gray-400 dark:bg-white/20 dark:group-hover:bg-white/40'"
            />
          </button>
        </div>

        <button
          v-if="canAutoplay"
          type="button"
          class="inline-flex items-center gap-1.5 rounded-full px-3 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
          :class="focusRing"
          @click="userPaused = !userPaused"
        >
          <svg v-if="userPaused" viewBox="0 0 20 20" aria-hidden="true" class="size-4 fill-current"><path d="M6.3 3.9A1 1 0 0 0 4.8 4.8v10.4a1 1 0 0 0 1.5.9l8.7-5.2a1 1 0 0 0 0-1.8L6.3 3.9Z" /></svg>
          <svg v-else viewBox="0 0 20 20" aria-hidden="true" class="size-4 fill-current"><path d="M5.5 4h2.5v12H5.5zM12 4h2.5v12H12z" /></svg>
          {{ userPaused ? 'Play' : 'Pause' }}
        </button>
        <span v-else class="w-10" aria-hidden="true" />
      </div>
    </div>
  </section>
</template>

Props

PropTypeDefaultDescription
testimonialsCarouselTestimonial[]4 sample quotesQuotes with quote, name, role and company.
headingstring'What teams say after switching'Small heading above the card; also labels the carousel.
autoplaybooleantrueAdvance automatically. Pauses on hover/focus and is disabled under prefers-reduced-motion.
intervalnumber6000Milliseconds per slide while autoplaying.
startIndexnumber0Index of the slide shown first.

Events

EventPayloadDescription
change(index: number)Fired whenever the visible slide changes, manually or by autoplay.
All testimonials →
Preview of Testimonial GridFree

Component · Testimonials

Testimonial Grid

Masonry grid of testimonial cards for Vue 3 with initials avatars, optional star ratings and highlighted quotes. Tailwind CSS, dark mode.

by Vueplay
Preview of CarouselFree

Component · Media & effects

Carousel

Carousel is a flexible Vue component for showcasing content with smooth horizontal or vertical rotation. Designed for both simplicity and style, it lets you present images, cards, or custom elements in an engaging, interactive way that adapts to any layout.

Preview of FAQ AccordionFree

Component · Accordions & FAQs

FAQ Accordion

Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.

by Vueplay
Preview of Mega Menu NavbarFree

Component · Navbars

Mega Menu Navbar

Vue 3 navbar with Products and Resources mega-menu panels, keyboard navigation and a mobile accordion. Built with Tailwind CSS, dark mode ready.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings