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.
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
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.
<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>“{{ t.quote }}”</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>
| Prop | Type | Default | Description |
|---|---|---|---|
| testimonials | CarouselTestimonial[] | 4 sample quotes | Quotes with quote, name, role and company. |
| heading | string | 'What teams say after switching' | Small heading above the card; also labels the carousel. |
| autoplay | boolean | true | Advance automatically. Pauses on hover/focus and is disabled under prefers-reduced-motion. |
| interval | number | 6000 | Milliseconds per slide while autoplaying. |
| startIndex | number | 0 | Index of the slide shown first. |
| Event | Payload | Description |
|---|---|---|
| change | (index: number) | Fired whenever the visible slide changes, manually or by autoplay. |
Component · Testimonials
Masonry grid of testimonial cards for Vue 3 with initials avatars, optional star ratings and highlighted quotes. Tailwind CSS, dark mode.
Component · Media & effects
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.
Component · Accordions & FAQs
Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.
Component · Navbars
Vue 3 navbar with Products and Resources mega-menu panels, keyboard navigation and a mobile accordion. Built with Tailwind CSS, dark mode ready.