A centred testimonial slider with one unusual move: the speaker's avatar rides the rim of a large soft disc, of which only the top slice is visible, so each new person swings in from the left along a curve while the last one swings out to the right. Underneath, the quote slides and un-blurs into place. A row of pills with each person's name and role works as the navigation, and the active pill fills with colour as a countdown to the next quote. It advances by itself, pauses while hovered or focused, and stops for good once someone picks a pill. On phones the pills wrap and the quote text steps down a size.
// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/testimonal-slider.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const testimonials = [
{ name: "Priya Venkataraman", role: "Bakery owner", initials: "PV", color: "#c2410c", quote: "I used to do the rota on the back of a flour sack. Now the team swaps shifts between themselves and I only hear about it when it is sorted." },
{ name: "Callum Driscoll", role: "Site foreman", initials: "CD", color: "#1d4ed8", quote: "Twenty two people across three sites, and for the first time everyone knows where to be on Monday before Monday." },
{ name: "Yasmin Okonjo", role: "Clinic manager", initials: "YO", color: "#0f766e", quote: "Holiday requests stopped arriving as sticky notes. That alone gave me back most of a Friday afternoon." },
{ name: "Henrik Aalto", role: "Cafe co-owner", initials: "HA", color: "#7e22ce", quote: "The students we hire for summer learn it in a minute. It is the only work app nobody has asked me how to use." },
];
const SECONDS = 6;
export default function TestimonialSliderWithAvatarsRotatingAlongAnArc() {
const reduce = useReducedMotion();
const [active, setActive] = useState(0);
const [auto, setAuto] = useState(true);
const [hover, setHover] = useState(false);
const running = auto && !hover && !reduce;
useEffect(() => {
if (!running) return;
const id = setTimeout(() => setActive((a) => (a + 1) % testimonials.length), SECONDS * 1000);
return () => clearTimeout(id);
}, [running, active]);
const t = testimonials[active];
return (
<section className="overflow-hidden bg-amber-50 px-4 py-16 sm:px-6 sm:py-24">
<style>{`
@keyframes testimonial-slider-with-avatars-rotating-along-an-arc-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
`}</style>
<div
className="mx-auto w-full max-w-4xl text-center"
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onFocus={() => setHover(true)}
onBlur={() => setHover(false)}
>
<h2 className="text-sm font-semibold uppercase tracking-widest text-orange-800">What owners say about Shiftwise</h2>
{/* The dial: a big soft disc, cropped to its top slice. Avatars ride its rim and swing in from the side. */}
<div className="relative mt-4 h-28 sm:h-36" aria-hidden="true">
<div className="pointer-events-none absolute left-1/2 top-0 h-[340px] w-[340px] -translate-x-1/2 sm:h-[480px] sm:w-[480px]">
<div className="absolute inset-0 [mask-image:linear-gradient(to_bottom,black,transparent_52%)]">
<div className="absolute inset-0 rounded-full bg-gradient-to-b from-orange-400/40 via-orange-300/10 to-transparent" />
<div className="absolute inset-3 rounded-full border border-dashed border-orange-700/30" />
</div>
<div className="absolute inset-0 [mask-image:linear-gradient(to_bottom,black_0,black_30%,transparent_44%)]">
<AnimatePresence initial={false}>
<motion.div
key={active}
className="absolute inset-0"
initial={reduce ? { opacity: 0 } : { opacity: 0, rotate: -60 }}
animate={{ opacity: 1, rotate: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, rotate: 60 }}
transition={{ duration: 0.75, ease: [0.68, -0.3, 0.32, 1] }}
>
<span
className="absolute left-1/2 top-6 flex h-16 w-16 -translate-x-1/2 items-center justify-center rounded-full text-lg font-bold text-white shadow-lg ring-4 ring-amber-50 sm:top-9 sm:h-20 sm:w-20 sm:text-xl"
style={{ backgroundColor: t.color }}
>
{t.initials}
</span>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
{/* All quotes share one grid cell, so the block is always as tall as the longest one and nothing jumps. */}
<div className="mx-auto grid max-w-3xl" aria-live={auto ? "off" : "polite"}>
{testimonials.map((item, i) => (
<motion.blockquote
key={item.name}
aria-hidden={i !== active}
className="col-start-1 row-start-1 px-2 text-xl font-semibold leading-snug tracking-tight text-stone-900 sm:text-3xl sm:leading-snug"
initial={false}
animate={i === active ? { opacity: 1, x: 0, filter: "blur(0px)" } : { opacity: 0, x: reduce ? 0 : 18, filter: reduce ? "blur(0px)" : "blur(6px)" }}
transition={{ duration: i === active ? 0.5 : 0.25, delay: i === active ? 0.25 : 0, ease: "easeOut" }}
>
<p>“{item.quote}”</p>
</motion.blockquote>
))}
</div>
<div className="mt-9 flex flex-wrap justify-center gap-2" role="group" aria-label="Choose a testimonial">
{testimonials.map((item, i) => (
<button
key={item.name}
type="button"
aria-pressed={i === active}
onClick={() => {
setActive(i);
setAuto(false);
}}
className={`relative inline-flex min-h-[40px] items-center gap-1.5 overflow-hidden rounded-full px-4 text-sm shadow-sm transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-700 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50 ${
i === active ? "bg-stone-900 text-white" : "bg-white text-stone-800 hover:bg-orange-100"
}`}
>
{i === active && auto && !reduce && (
<span
key={active}
className="absolute inset-0 origin-left bg-orange-600"
style={{ animation: `testimonial-slider-with-avatars-rotating-along-an-arc-fill ${SECONDS}s linear forwards`, animationPlayState: hover ? "paused" : "running" }}
aria-hidden="true"
/>
)}
<span className="relative font-semibold">{item.name}</span>
<span className={`relative ${i === active ? "text-white/85" : "text-stone-600"}`}>{item.role}</span>
</button>
))}
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/testimonial-slider-with-avatars-rotating-along-an-arc.json
Each avatar is pinned to the top of a square layer the same size as the disc. Rotating that whole layer around its centre carries the avatar along the rim. A mask hides everything below the top slice, so you only see the part of the journey near the top.
All quotes are rendered in the same CSS grid cell, stacked on top of each other, and only the active one is visible. The cell is always as tall as the longest quote, so the pills below never move.
Replace the coloured circle with an `img` of the same size and `rounded-full`, and add an `alt` with the person's name. Keep the white ring so the photo separates from the dial.
Yes. Delete the span with the `-fill` animation inside the pill. The timer that advances the slider is separate and keeps working.
It can be if it cannot be stopped. Here it pauses on hover and focus, ends when a pill is pressed, and never starts for people who ask for reduced motion, which covers the usual requirements.
Adapted from Testimonial Slider in Eldora UI, used under its open-source licence. Licences and credits
Related: Large Quote Testimonial with Author and Role, Testimonial Cards with Star Ratings and Initials Avatars, Three Column Testimonial Wall with Vertical Scroll.
More testimonials or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.