Skip to content

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

Testimonials Component 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

EventPayloadDescription
card-dragged{ cardIndex, position }Emitted when a card is dragged

Data Properties

PropertyTypeDescription
displayTestimonialsArrayArray of testimonial objects
isVisiblebooleanIntersection observer visibility state
isDraggingbooleanCurrent 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>

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

Built with love by mhdevfr