Testimonials
A vertically scrolling multi-column testimonials grid with unified theme-aware animations and pause-on-hover logic.
Overview
The Testimonials component is a pure, high-performance motion primitive designed to provide dense social proof. It features three vertically scrolling columns with independent speeds and directions, creating a dynamic mosaic effect that feels balanced and premium.
By removing section-level headers and footers, it offers maximum flexibility, allowing you to drop the grid into any custom layout or landing page section.
Features
- Dynamic Mosaic Grid: Three columns of testimonials scrolling at independent speeds for a lively, high-end feel.
- Unified Theme Aesthetics: Glint effects and hover states are perfectly synchronized across Light and Dark modes.
- Pure Component Logic: Focuses strictly on the grid, with no forced headers, badges, or logo strips.
- Infinite Loop: Seamlessly duplicates content to ensure a never-ending, buttery-smooth scroll experience.
- Smart Interaction: Automatic pause-on-hover logic ensures users can read specific testimonials comfortably.
Preview
“ZenBlocks cut our development time by 40%. The physics-enabled components are a game changer for our UX.”
“The copy-paste DX is incredible. We launched our landing page 3 days early thanks to these motion primitives.”
“Our conversion rate increased by 15% after implementing the Orb Field and Testimonials mosaic. Pure results.”
“ZenBlocks cut our development time by 40%. The physics-enabled components are a game changer for our UX.”
“The copy-paste DX is incredible. We launched our landing page 3 days early thanks to these motion primitives.”
“Our conversion rate increased by 15% after implementing the Orb Field and Testimonials mosaic. Pure results.”
“ZenBlocks cut our development time by 40%. The physics-enabled components are a game changer for our UX.”
“The copy-paste DX is incredible. We launched our landing page 3 days early thanks to these motion primitives.”
“Our conversion rate increased by 15% after implementing the Orb Field and Testimonials mosaic. Pure results.”
“ZenBlocks cut our development time by 40%. The physics-enabled components are a game changer for our UX.”
“The copy-paste DX is incredible. We launched our landing page 3 days early thanks to these motion primitives.”
“Our conversion rate increased by 15% after implementing the Orb Field and Testimonials mosaic. Pure results.”
Installation
Method 1: CLI (Recommended)
Quickly add the component to your project:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/testimonials.jsonMethod 2: Manual
-
Install Dependencies
npm install framer-motion lucide-react clsx tailwind-merge -
Copy the Source Code
Create
components/zenblocks/testimonials.tsxand paste the following:
Click to expand source
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
} from "framer-motion";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* TYPES */
/* -------------------------------------------------------------------------- */
export type Testimonial = {
text: string;
name: string;
role: string;
image?: string;
rating?: number;
};
export interface TestimonialsColumnProps {
testimonials: Testimonial[];
duration?: number;
className?: string;
reverse?: boolean;
pauseOnHover?: boolean;
showRatings?: boolean;
}
export interface TestimonialsProps {
testimonials?: Testimonial[];
pauseOnHover?: boolean;
showRatings?: boolean;
className?: string;
}
/* -------------------------------------------------------------------------- */
/* CONSTANTS */
/* -------------------------------------------------------------------------- */
export const DEFAULT_TESTIMONIALS: Testimonial[] = [
{
text: "ZenBlocks cut our development time by 40%. The physics-enabled components are a game changer for our UX.",
name: "Alex Rivera",
role: "Senior Frontend Engineer",
image: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "The copy-paste DX is incredible. We launched our landing page 3 days early thanks to these motion primitives.",
name: "Sarah Chen",
role: "Product Designer @ TechFlow",
image: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "Our conversion rate increased by 15% after implementing the Orb Field and Testimonials mosaic. Pure results.",
name: "Marcus Johnson",
role: "Creative Developer",
image: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "Type safety and documentation are top-notch. Integration took less than 10 minutes from start to finish.",
name: "Emily Rodriguez",
role: "Lead Developer @ Nexus",
image: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=80&h=80&fit=crop&crop=face",
rating: 4,
},
{
text: "Mobile performance is flawless. Even with heavy animations, our Lighthouse score stayed at a solid 100.",
name: "David Park",
role: "Mobile App Architect",
image: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "The ownership model is why I chose ZenBlocks. No subscriptions, just high-quality code that I own.",
name: "Aisha Patel",
role: "Fullstack Developer",
image: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "We scaled our SaaS from 1k to 10k MRR with ZenBlocks. The industrial aesthetic gives us an elite edge.",
name: "James Wilson",
role: "CEO @ Velocity",
image: "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "Clean, minimal, and powerful. It is the only component library that understands industrial-grade design.",
name: "Lisa Thompson",
role: "VP of Engineering",
image: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
{
text: "The animation engine is robust. We have zero jank on low-end devices, which is critical for our global user base.",
name: "Michael Brown",
role: "Startup Founder",
image: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=80&h=80&fit=crop&crop=face",
rating: 4,
},
];
/* -------------------------------------------------------------------------- */
/* INTERNAL CARD */
/* -------------------------------------------------------------------------- */
const TestimonialCard = ({
testimonial,
showRatings
}: {
testimonial: Testimonial;
showRatings?: boolean
}) => {
const initials = testimonial.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<div className="group relative rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-5 hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50 hover:border-zinc-300 dark:hover:border-zinc-700 hover:shadow-zinc-950/[0.04] dark:hover:shadow-white/[0.04] transition-all duration-300 overflow-hidden">
{/* Unified Glint Effect - Balanced for Light and Dark Modes */}
<div className="absolute inset-0 pointer-events-none z-0">
<div className="absolute inset-0 bg-gradient-to-tr from-transparent via-white/40 dark:via-white/15 to-transparent translate-x-[-100%] group-hover:translate-x-[200%] transition-transform duration-1000" />
</div>
<div className="relative z-10 flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full border border-zinc-200 dark:border-zinc-800">
{testimonial.image ? (
<img
src={testimonial.image}
alt={testimonial.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-zinc-200 dark:bg-zinc-800 text-[10px] font-bold text-zinc-600 dark:text-zinc-400">
{initials}
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{testimonial.name}
</span>
<span className="text-[11px] text-zinc-500 dark:text-zinc-400 font-mono">
{testimonial.role}
</span>
</div>
</div>
{showRatings && testimonial.rating && (
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={12}
className={cn(
"transition-colors",
i < testimonial.rating!
? "fill-zinc-900 dark:fill-white text-zinc-900 dark:text-white"
: "fill-zinc-200 dark:fill-zinc-800 text-zinc-200 dark:text-zinc-800"
)}
/>
))}
</div>
)}
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
“{testimonial.text}”
</p>
</div>
</div>
);
};
/* -------------------------------------------------------------------------- */
/* COLUMN */
/* -------------------------------------------------------------------------- */
export const TestimonialsColumn = ({
testimonials,
duration = 20,
className,
reverse = false,
pauseOnHover = true,
showRatings = true,
}: TestimonialsColumnProps) => {
const [isPaused, setIsPaused] = useState(false);
const columnRef = useRef<HTMLDivElement>(null);
const [columnHeight, setColumnHeight] = useState(0);
// Motion values for the animation
const y = useMotionValue(0);
// Duplicate for seamless scroll
const duplicatedTestimonials = useMemo(() => [
...testimonials,
...testimonials,
...testimonials,
...testimonials,
], [testimonials]);
useEffect(() => {
if (columnRef.current) {
setColumnHeight(columnRef.current.scrollHeight / 4);
}
}, [testimonials]);
useAnimationFrame((time, delta) => {
if (isPaused && pauseOnHover) return;
const moveAmount = (delta / 1000) * (columnHeight / duration);
let currentY = y.get();
if (reverse) {
currentY += moveAmount;
if (currentY > 0) currentY = -columnHeight;
} else {
currentY -= moveAmount;
if (currentY < -columnHeight) currentY = 0;
}
y.set(currentY);
});
return (
<div
className={cn(
"relative flex flex-col gap-6 max-h-[640px] overflow-hidden",
"[mask-image:linear-gradient(to_bottom,transparent,black_15%,black_85%,transparent)]",
className
)}
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<motion.div
ref={columnRef}
style={{ y }}
className="flex flex-col gap-6"
>
{duplicatedTestimonials.map((testimonial, i) => (
<TestimonialCard
key={`${testimonial.name}-${i}`}
testimonial={testimonial}
showRatings={showRatings}
/>
))}
</motion.div>
</div>
);
};
/* -------------------------------------------------------------------------- */
/* MOSAIC */
/* -------------------------------------------------------------------------- */
export function Testimonials({
testimonials = DEFAULT_TESTIMONIALS,
pauseOnHover = true,
showRatings = true,
className,
}: TestimonialsProps) {
// Use DEFAULT_TESTIMONIALS if none provided
const items = (testimonials && testimonials.length > 0) ? testimonials : DEFAULT_TESTIMONIALS;
const col1 = items.slice(0, 3);
const col2 = items.slice(3, 6);
const col3 = items.slice(6, 9);
return (
<div
className={cn(
"relative w-full max-w-7xl mx-auto px-6",
className
)}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 justify-center">
<TestimonialsColumn
testimonials={col1}
duration={15}
pauseOnHover={pauseOnHover}
showRatings={showRatings}
/>
<TestimonialsColumn
testimonials={col2}
duration={19}
className="hidden md:flex"
reverse={true}
pauseOnHover={pauseOnHover}
showRatings={showRatings}
/>
<TestimonialsColumn
testimonials={col3}
duration={17}
className="hidden lg:flex"
pauseOnHover={pauseOnHover}
showRatings={showRatings}
/>
</div>
</div>
);
}
export default Testimonials;Usage
import { Testimonials } from "@/components/zenblocks/testimonials";
const myTestimonials = [
{
text: "ZenBlocks cut our development time by 40%. The physics-enabled components are a game changer.",
name: "Alex Rivera",
role: "Senior Frontend Engineer",
image: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=80&h=80&fit=crop&crop=face",
rating: 5,
},
// Add more testimonials...
];
export default function Example() {
return (
<section className="py-24 bg-white dark:bg-zinc-950 transition-colors">
<h2 className="text-center text-4xl font-bold text-zinc-900 dark:text-white mb-16">
Trusted by Developers
</h2>
<Testimonials testimonials={myTestimonials} />
</section>
);
}Props
Testimonials
| Prop | Type | Default | Description |
|---|---|---|---|
testimonials | Testimonial[] | DEFAULT_TESTIMONIALS | Array of testimonial objects to display. |
pauseOnHover | boolean | true | Whether to pause the scrolling animation on hover. |
showRatings | boolean | true | Whether to display star ratings on the cards. |
className | string | - | Additional CSS classes for the container. |
Accessibility
- Motion Reduction: Respects
prefers-reduced-motionsettings through Framer Motion. - Visual Feedback: The pause state on hover communicates interactivity clearly across all themes.
- Contrast: Monochrome Star ratings ensure readable contrast in both modes.
Performance
- Animation Optimization: Uses
useAnimationFramefor hardware-accelerated scroll logic. - Memory Management: Efficient content duplication via
useMemo. - Theme Stability: Unified animation tokens prevent layout shifts when toggling Dark/Light mode.