Accessible horizontal slider for testimonials. Uses a scroll-snap track with keyboard focus, swipe gestures via a composable, and previous/next arrow buttons with disabled states. Includes a scoped slot to fully customize each card.
Prop | Type | Default / Req. | Description |
---|---|---|---|
items | Array | required | List of testimonial objects rendered as slides. |
default
— Customize each slide’s markup.@nuxt/icon
You may use these UI components in your own personal or commercial projects. You may not resell, redistribute, sublicense, or package them as standalone assets or template/library packs.
Full terms: End-User License Agreement
Below you can expand the main implementation file and any supporting components. Use the “Copy” button to grab a snippet straight to your clipboard.
These are the raw components that are required to run this example. Copy-paste them into your project. Most likely you will not change anything in these files, but you can if you want to. These are the components that are used in the main implementation file.
<script setup>
const props = defineProps({
items: {
type: Array,
required: true,
},
});
const { carouselRef, current, next, prev, canNext, canPrev } = useCarousel(
computed(() => props.items.length)
);
</script>
<template>
<section class="relative" aria-label="Testimonials slider">
<ol
ref="carouselRef"
class="flex gap-4 overflow-x-auto px-4 py-2 scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden select-none"
role="listbox"
tabindex="0"
>
<li
v-for="(item, i) in props.items"
:key="i"
:data-idx="i"
role="option"
:aria-selected="i === current"
class="snap-start shrink-0 w-[85%] sm:w-[65%] md:w-[55%] lg:w-[40%]"
>
<slot :item="item" :index="i">
<!-- Default -->
<figure
class="border border-gray-200 rounded-lg p-4 h-full bg-iq-primary"
>
<img
:src="item.author.image"
:alt="`Photo of ${item.author.name}`"
class="size-16 md:size-20 rounded-full object-cover mb-2 shrink-0"
/>
<figcaption class="text-lg md:text-xl font-medium text-gray-800 shrink-0">
{{ item.author.name }}
</figcaption>
<div
class="flex items-center gap-1 mb-2 shrink-0"
:title="`${item.rating}/5`"
aria-label="Rating"
>
<span
v-for="n in 5"
:key="n"
class="text-base md:text-xl"
:class="n <= item.rating ? 'text-amber-400' : 'text-gray-300'"
aria-hidden="true"
>★</span
>
</div>
<blockquote class="text-gray-700 leading-relaxed text-pretty line-clamp-5">
“{{ item.text }}”
</blockquote>
</figure>
</slot>
</li>
</ol>
<!-- arrows -->
<div class="mt-3 flex items-center justify-between">
<button
type="button"
class="inline-flex items-center justify-center disabled:opacity-40 text-3xl md:text-5xl text-iq-primary active:scale-95 hover:scale-105 cursor-pointer"
aria-label="Previous"
:disabled="!canPrev"
@click="prev"
>
<Icon name="material-symbols:arrow-circle-left-rounded" />
</button>
<button
type="button"
class="inline-flex items-center justify-center disabled:opacity-40 text-3xl md:text-5xl text-iq-primary active:scale-95 hover:scale-105 cursor-pointer"
aria-label="Next"
:disabled="!canNext"
@click="next"
>
<Icon name="material-symbols:arrow-circle-right-rounded" />
</button>
</div>
<!-- SR status -->
<p class="sr-only" aria-live="polite">
Slide {{ current + 1 }} of {{ props.items.length }}
</p>
</section>
</template>
// composables/useCarousel.js
import { useSwipe, useIntersectionObserver } from '@vueuse/core'
export function useCarousel(itemsLength) {
const slideSelector = '[data-idx]'
const swipeThreshold = 30
const ioThreshold = [0, 0.01]
const behavior = 'smooth'
const carouselRef = ref(null)
const current = ref(0)
const lastElementFullyVisible = ref(false)
const canPrev = computed(() => current.value > 0)
const canNext = computed(() => !lastElementFullyVisible.value)
const ariaStatus = computed(() => `Slide ${current.value + 1} of ${itemsLength.value}`)
let cleanupFns = []
function goTo(i, scrollBehavior = behavior) {
const root = carouselRef.value
if (!root || itemsLength.value <= 0) return
const targetIndex = i
const el = root.querySelector(`[data-idx="${targetIndex}"]`)
el?.scrollIntoView({ behavior: scrollBehavior, inline: 'start', block: 'nearest' })
}
function next() {
goTo(current.value + 1)
}
function prev() {
goTo(current.value - 1)
}
// Swipe: decide direction on release
useSwipe(carouselRef, {
onSwipeEnd(_, dir, dist) {
if (Math.abs(dist.x) < swipeThreshold) return goTo(current.value)
if (dir === 'left') return next()
if (dir === 'right') return prev()
},
})
// Initialize IntersectionObservers per slide, scoped to the scroller
async function initObservers() {
// clear any previous observers
cleanup()
await nextTick()
const root = carouselRef.value
if (!root) return
const slides = Array.from(root.querySelectorAll(slideSelector))
const visible = new Array(slides.length).fill(false)
slides.forEach((el, idx) => {
const { stop } = useIntersectionObserver(
el,
([entry]) => {
// mark visibility for this slide
visible[idx] = entry.isIntersecting && entry.intersectionRatio > 0
// pick the smallest visible index
for (let i = 0; i < visible.length; i++) {
if (visible[i]) {
current.value = i
break
}
}
},
{
root,
threshold: ioThreshold,
}
)
cleanupFns.push(stop)
})
lastElementFullyVisible.value = false
const lastEl = slides[slides.length - 1]
if (lastEl) {
const { stop } = useIntersectionObserver(
lastEl,
([entry]) => {
// true when last slide is at least X% in view
lastElementFullyVisible.value = entry.isIntersecting && entry.intersectionRatio >= 1
},
{ root, threshold: 1 } // minimal, no fancy arrays needed
)
cleanupFns.push(stop)
}
}
function cleanup() {
cleanupFns.forEach(fn => fn())
cleanupFns = []
lastElementFullyVisible.value = false
}
onMounted(initObservers)
onBeforeUnmount(cleanup)
// Re-init when number of items changes or container ref becomes available
watch([itemsLength, carouselRef], initObservers, { flush: 'post' })
return {
// state
current,
carouselRef,
// actions
goTo,
next,
prev,
// derived
canPrev,
canNext,
ariaStatus,
lastElementFullyVisible
}
}
This is the main Vue file that uses the component. Copy-paste this into your project. In this code feel free to change anything you like, such as the component name, props, or class. This is the place where you control the main component.
<script setup>
// Dummy data for `items` prop
const items = [
{
author: { name: "John Smith", image: "https://i.pravatar.cc/160?img=15" },
rating: 5,
text: "Service was fast and surprisingly friendly. Would use again."
},
{
author: { name: "Elisabeth Gremiln", image: "https://i.pravatar.cc/160?img=12" },
rating: 4,
text: "Solid experience overall. A few rough edges, nothing fatal."
},
{
author: { name: "Alex Nakedyn", image: "https://i.pravatar.cc/160?img=32" },
rating: 5,
text: "Exactly what I needed. Clean UI and zero fuss."
},
{
author: { name: "John Doe", image: "https://i.pravatar.cc/160?img=48" },
rating: 3,
text: "Does the job. Could be snappier on older phones."
},
{
author: { name: "Caroline Belzebos", image: "https://i.pravatar.cc/160?img=5" },
rating: 4,
text: "Nice little quality-of-life features sprinkled everywhere."
},
{
author: { name: "Mark Cremling", image: "https://i.pravatar.cc/160?img=27" },
rating: 2,
text: "Worked, then hiccuped once. Support helped, so not a total loss."
},
{
author: { name: "Victor Orben", image: "https://i.pravatar.cc/160?img=20" },
rating: 5,
text: "Smooth onboarding and clear docs. Rare and appreciated."
},
{
author: { name: "Tomas Clown", image: "https://i.pravatar.cc/160?img=7" },
rating: 4,
text: "Neat animations. Doesn’t feel bloated, which is a miracle."
},
{
author: { name: "Victoria Maxima", image: "https://i.pravatar.cc/160?img=18" },
rating: 5,
text: "Fast, stable, and doesn’t nag me. Five stars just for that."
},
{
author: { name: "Paul Morphy", image: "https://i.pravatar.cc/160?img=40" },
rating: 3,
text: "Good baseline. If you add offline mode, it’s an easy 5."
}
];
</script>
<template>
<div>
<p class="bg-white p-4 rounded m-4">
Testimonials slider with default testimony cards with primary color background.
<br>
<strong>You can override default testimony with v-slots</strong>
</p>
<TestimonialsSlider
class="max-w-7xl mx-auto my-24"
:items="items" />
</div>
</template>
Decide whether you want a global design-system or a one-off inline snippet.
Complete @theme
block – import once and share across every component.
Copy the code below into main.css
file. It is most likely in assets/css/main.css
directory.
:style
binding – paste straight onto any of ours components.
Copy the code below and paste it into the :style
binding of the component.
I wish I could automate every little thing—but for now you’ll need to handle these final steps by hand. Apologies for the extra work!
iq-card-*
style Now that you’ve picked a card preset, copy its CSS into your @layer components
block in main.css
. This ensures every `iq-card
` wrapper will look just right.
iq-cta
iq-cta is the main call to action button class. It’s used in many places across the components. But for now it is only a single class that you can customize. You can copy the code below and paste it into your @layer components
block in main.css
. In future you will be able to fully customize it from our UI and choose from many presets.
I didn't have time to figure out consistency. Although there are no actions required, be mindful that the forms might not be entirely consistent with the design system. A quick once-over will keep everything looking sharp.