Testimonial Carousel
Installation
npx shadcn@latest add https://uibrik.com/r/testimonial-carousel.jsonInstall the packages
npm install motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/testimonial-carousel/testimonial-carousel.tsx"use client"; import { useEffect, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; // Testimonial carousel. One quote at a time, with the pager under it. The // pager is the fixed point and never moves: every quote sits in the same grid // cell, so the frame is as tall as the longest quote, and each is anchored to // the bottom of it. A shorter quote therefore rests lower, against the pager, // and a longer one reaches up. Only the visible quote is painted; the others // are faded out and inert. export type Testimonial = { quote: string; name: string; role?: string; }; export type TestimonialCarouselProps = { testimonials?: Testimonial[]; /** Advance on a timer, pausing while the pointer or focus is inside. */ autoplay?: boolean; /** Seconds each quote stays, when autoplay is on. */ interval?: number; className?: string; }; const TESTIMONIALS: Testimonial[] = [ { quote: "We replaced three tools with this one. The team stopped asking where things live.", name: "Maya Chen", role: "Head of Product, Northwind", }, { quote: "Setup took an afternoon. Six months later nobody has needed to think about it, which is exactly what we wanted from the infrastructure underneath our whole release process.", name: "Daniel Okafor", role: "Engineering Lead, Fieldnote", }, { quote: "Calm, fast and honest about what it does.", name: "Priya Raman", role: "Founder, Lumen", }, { quote: "Our support load dropped by a third in the first quarter, and the people who stayed got better at their jobs.", name: "Lukas Meyer", role: "COO, Tidepool", }, ]; const EASE_OUT = [0.23, 1, 0.32, 1] as const; const FOCUS = "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-400 dark:focus-visible:outline-zinc-500"; const initials = (name: string) => name .split(/\s+/) .map((w) => w[0]) .slice(0, 2) .join("") .toUpperCase(); export function TestimonialCarousel({ testimonials = TESTIMONIALS, autoplay = true, interval = 2, className, }: TestimonialCarouselProps) { const reduce = useReducedMotion(); const [active, setActive] = useState(0); const [paused, setPaused] = useState(false); const count = testimonials.length; const index = Math.min(active, count - 1); useEffect(() => { if (!autoplay || paused || count < 2) return; const id = window.setTimeout(() => setActive((i) => (i + 1) % count), interval * 1000); return () => window.clearTimeout(id); }, [autoplay, paused, interval, count, active]); if (count === 0) return null; return ( <section aria-roledescription="carousel" aria-label="Testimonials" onPointerEnter={() => setPaused(true)} onPointerLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} className={cn("w-full max-w-lg", className)} > <div className="grid" aria-live={autoplay ? "off" : "polite"}> {testimonials.map((t, i) => { const on = i === index; return ( <motion.figure key={t.name} inert={!on} aria-hidden={!on} initial={false} animate={{ opacity: on ? 1 : 0, y: on ? 0 : reduce ? 0 : 10, filter: on || reduce ? "blur(0px)" : "blur(4px)", }} // Out fast, then in, so two quotes are never legible at once. transition={ reduce ? { duration: 0 } : on ? { duration: 0.35, delay: 0.1, ease: EASE_OUT } : { duration: 0.15, ease: EASE_OUT } } className="col-start-1 row-start-1 flex flex-col justify-end gap-6" > <blockquote className="text-pretty text-xl leading-snug font-medium text-foreground">“{t.quote}”</blockquote> <figcaption className="flex items-center gap-3"> <span aria-hidden className="edge-tile grid size-10 shrink-0 place-items-center rounded-full bg-muted text-sm font-medium text-foreground" > {initials(t.name)} </span> <span className="flex min-w-0 flex-col"> <span className="truncate text-sm font-medium text-foreground">{t.name}</span> {t.role && <span className="truncate text-[13px] text-muted-foreground">{t.role}</span>} </span> </figcaption> </motion.figure> ); })} </div> <div className="mt-8 flex items-center gap-1" role="tablist" aria-label="Choose a testimonial"> {testimonials.map((t, i) => { const on = i === index; return ( <button key={t.name} type="button" role="tab" aria-selected={on} aria-label={`${t.name}, ${i + 1} of ${count}`} onClick={() => setActive(i)} className={cn("grid h-6 place-items-center rounded-full px-0.5", FOCUS)} > <span className={cn( "block h-1.5 rounded-full transition-[width,background-color] duration-300", on ? "w-6 bg-foreground" : "w-1.5 bg-foreground/25 hover:bg-foreground/45", )} /> </button> ); })} </div> </section> ); }
Adds the files to
components/uibrik/testimonial-carousel and installs motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/testimonial-carousel.Props
TestimonialCarousel- autoplay
- Type boolean
- true
- Advance on a timer. Pauses while the pointer or focus is inside.
- interval
- Type number
- 2
- How long each quote stays with autoplay on.
- testimonials
- Type { quote: string; name: string; role?: string }[]
- 4 sample quotes
- The testimonials, in order.
- className
- Type string
- –
- Classes for the wrapper.