Testimonials Component
Interactive testimonials section with draggable cards, smooth animations, and intersection observer effects. Showcases customer feedback in an engaging visual format.
Overview
The Testimonials component creates an interactive testimonials section where customer testimonial cards can be dragged around, creating a playful and engaging user experience. It includes smooth animations, responsive design, and intersection observer for scroll-triggered effects.
Preview
Basic Usage
vue
<template>
<section class="py-16">
<Testimonials />
</section>
</template>
<script setup lang="ts">
import Testimonials from '@/components/Testimonials.vue'
</script>Features
🎯 Interactive Cards
- Draggable testimonial cards
- Touch support for mobile devices
- Smooth drag animations
- Cursor state changes (grab/grabbing)
✨ Scroll Animations
- Intersection observer integration
- Staggered card entrance animations
- Fade-in and slide-up effects
- Delay timing for each card
📱 Responsive Design
- Mobile-optimized layout
- Touch gesture support
- Responsive card positioning
- Adaptive text sizing
🌍 Internationalization
- Full i18n support for all text content
- Translatable testimonial content
- Multi-language descriptions
Component Structure
vue
<template>
<div class="testimonials-container">
<!-- Section Header -->
<h1 ref="testimonialsRef">{{ $t('testimonials.title') }}</h1>
<h2>{{ $t('testimonials.bottomTitle') }}</h2>
<p>{{ $t('testimonials.description') }}</p>
<!-- Interactive Cards Area -->
<div class="relative w-full lg:max-w-4xl max-w-2xl h-64">
<div
v-for="(testimonial, index) in displayTestimonials"
:key="index"
class="absolute cursor-grab"
:style="getCardTransform(index)"
@mousedown="startDrag(index, $event)"
@touchstart="startDrag(index, $event)"
>
<TestimonialCard
:imageUrl="testimonial.imageUrl"
:firstName="testimonial.firstName"
:lastName="testimonial.lastName"
:title="testimonial.title"
:content="testimonial.content"
/>
</div>
</div>
</div>
</template>Component API
Props
This component doesn't accept props. Testimonial data is managed internally.
Events
| Event | Payload | Description |
|---|---|---|
card-dragged | { cardIndex, position } | Emitted when a card is dragged |
Data Properties
| Property | Type | Description |
|---|---|---|
displayTestimonials | Array | Array of testimonial objects |
isVisible | boolean | Intersection observer visibility state |
isDragging | boolean | Current drag state |
Drag Functionality
Drag Implementation
typescript
const startDrag = (cardIndex: number, event: MouseEvent | TouchEvent) => {
isDragging.value = true
currentDragIndex.value = cardIndex
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY
dragOffset.value = {
x: clientX - cardPositions.value[cardIndex].x,
y: clientY - cardPositions.value[cardIndex].y
}
}Touch Support
typescript
// Touch events for mobile
@touchstart="startDrag(index, $event)"
@touchmove="onDrag"
@touchend="stopDrag"
// Mouse events for desktop
@mousedown="startDrag(index, $event)"
@mousemove="onDrag"
@mouseup="stopDrag"Animation Details
Intersection Observer
typescript
const { stop } = useIntersectionObserver(
testimonialsRef,
([{ isIntersecting }]) => {
isVisible.value = isIntersecting
},
{ threshold: 0.3 }
)Staggered Animations
vue
<div
class="transition-all duration-1000 ease-out"
:class="[
getTestimonialPosition(index),
{
'delay-800': index === 0,
'delay-950': index === 1,
'delay-1100': index === 2,
'delay-1250': index === 3
}
]"
>Card Transform
typescript
const getCardTransform = (index: number) => {
return {
x: cardPositions.value[index]?.x || 0,
y: cardPositions.value[index]?.y || 0,
rotation: cardPositions.value[index]?.rotation || 0
}
}Testimonial Data Structure
typescript
interface Testimonial {
id: string
imageUrl: string
firstName: string
lastName: string
title: string
content: string
rating?: number
zIndex?: number
}Example Data
typescript
const testimonials = [
{
id: '1',
imageUrl: '/testimonials/john-doe.jpg',
firstName: 'John',
lastName: 'Doe',
title: 'CEO at TechCorp',
content: 'ClawPlate helped us launch our SaaS in record time. Amazing!',
rating: 5
}
]Internationalization
Configure testimonials content in your i18n files:
json
{
"testimonials": {
"title": "What Our Customers Say",
"bottomTitle": "Trusted by 1000+",
"bottomSubtitle": "developers worldwide",
"bottomDescription": "Join the community of successful SaaS builders",
"description": "See how ClawPlate has helped developers and entrepreneurs launch their SaaS products faster than ever before."
}
}Styling Details
Container Animation
vue
<div
class="flex flex-col items-center justify-center w-full transition-all duration-800 ease-out"
:class="{
'opacity-100 translate-y-0': isVisible,
'opacity-0 translate-y-24': !isVisible
}"
>Card Positioning
vue
<div
class="absolute cursor-grab active:cursor-grabbing select-none transition-all duration-1000 ease-out"
:style="{
zIndex: testimonial.zIndex || 1,
transform: `translate3d(${x}px, ${y}px, 0px) rotate(${rotation}deg) scale(1)`
}"
>Typography Hierarchy
vue
<!-- Main title -->
<h1 class="lg:text-5xl text-4xl font-bold font-heading text-center mb-4">
<!-- Secondary title -->
<h2 class="text-3xl font-bold text-gray-900 mb-2">
<!-- Description -->
<p class="text-lg text-gray-500 text-center mb-8 max-w-2xl">Customization
Changing Card Layout
typescript
const getInitialPosition = (index: number) => {
const positions = [
{ x: 0, y: 0, rotation: -5 },
{ x: 200, y: 20, rotation: 3 },
{ x: 100, y: 40, rotation: -2 },
{ x: 300, y: 10, rotation: 4 }
]
return positions[index] || { x: 0, y: 0, rotation: 0 }
}Custom Animation Timing
vue
<!-- Faster animations -->
<div class="transition-all duration-500 ease-out">
<!-- Custom delays -->
:class="{
'delay-500': index === 0,
'delay-700': index === 1,
'delay-900': index === 2
}"Different Card Arrangements
typescript
// Circular arrangement
const getCircularPosition = (index: number, total: number) => {
const angle = (index / total) * 2 * Math.PI
const radius = 150
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
rotation: (angle * 180) / Math.PI
}
}TestimonialCard Integration
The component uses TestimonialCard for individual testimonials:
vue
<TestimonialCard
:imageUrl="testimonial.imageUrl"
:firstName="testimonial.firstName"
:lastName="testimonial.lastName"
:title="testimonial.title"
:content="testimonial.content"
:rating="testimonial.rating"
/>Accessibility
Keyboard Navigation
vue
<div
tabindex="0"
@keydown="handleKeyboardDrag"
role="button"
:aria-label="`Testimonial from ${testimonial.firstName} ${testimonial.lastName}`"
>Screen Reader Support
vue
<div aria-live="polite" class="sr-only">
{{ isDragging ? 'Moving testimonial card' : 'Testimonial cards ready to interact' }}
</div>Focus Management
typescript
const handleKeyboardDrag = (event: KeyboardEvent) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
event.preventDefault()
// Handle keyboard-based card movement
}
}Performance Optimization
Efficient Drag Handling
typescript
// Use RAF for smooth animations
const updateCardPosition = () => {
if (isDragging.value) {
requestAnimationFrame(updateCardPosition)
}
}Memory Management
typescript
onUnmounted(() => {
// Clean up event listeners
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
})Integration Examples
With Analytics
vue
<script setup lang="ts">
const trackTestimonialInteraction = (action: string, testimonialId: string) => {
gtag('event', 'testimonial_interaction', {
event_category: 'engagement',
event_label: testimonialId,
custom_parameter: action
})
}
const startDrag = (cardIndex: number, event: Event) => {
trackTestimonialInteraction('drag_start', displayTestimonials.value[cardIndex].id)
// ... drag logic
}
</script>With Custom Data Source
vue
<script setup lang="ts">
const { data: testimonials } = await useFetch('/api/testimonials')
const displayTestimonials = computed(() => testimonials.value || [])
</script>Related Components
Dependencies
- TestimonialCard - Individual testimonial component
- @vueuse/core - For intersection observer
- Nuxt i18n - For internationalization
- Vue 3 - Composition API and reactivity
- Tailwind CSS - For styling and animations
