Crossfading testimonial
A quote crossfade picked by monogram chips that expand into a name on hover
npx shadcn@latest add https://ethankd.com/r/testimonial-spotlight.jsonThis place gave me a language for what I'd only ever felt.
"use client";
import { useState } from "react";
const TESTIMONIALS = [
{ author: "Grace M.", body: "This place gave me a language for what I'd only ever felt." },
{ author: "Samuel O.", body: "I came in unsure and left with a calling I couldn't ignore." },
{ author: "Ruth A.", body: "The kind of teaching that rearranges how you read everything after." },
];
// Honorifics dropped so a monogram reads as a person's actual initials.
const HONORIFICS = new Set(["sister", "sr", "brother", "br", "pr", "pastor", "rev", "dr", "mr", "mrs", "ms"]);
function initials(name: string): string {
const words = name.split(/\s+/);
const real = words.filter((w) => !HONORIFICS.has(w.replace(/\./g, "").toLowerCase()));
const pick = real.length ? real : words;
const first = pick[0]?.[0] ?? "";
const last = pick.length > 1 ? pick[pick.length - 1][0] : "";
return (first + last).toUpperCase();
}
const SPRING = "ease-[cubic-bezier(0.32,0.72,0,1)]";
/**
* A quote that crossfades on selection, picked by a row of monogram chips
* that expand into the person's name on hover. The active chip stays a
* solid dot rather than duplicating the name already sitting in the caption.
*/
export function TestimonialSpotlight() {
const [active, setActive] = useState(0);
const [shown, setShown] = useState(0);
const [animating, setAnimating] = useState(false);
const [hovered, setHovered] = useState<number | null>(null);
const select = (i: number) => {
if (i === active || animating) return;
setAnimating(true);
setActive(i);
setTimeout(() => {
setShown(i);
setTimeout(() => setAnimating(false), 400);
}, 200);
};
const t = TESTIMONIALS[shown];
return (
<div className="flex flex-col items-center rounded-2xl bg-[#eef1ea] px-6 py-14">
<figure className="flex w-full max-w-xl flex-col items-center">
<div className="relative px-4 sm:px-8">
<span aria-hidden className="pointer-events-none absolute -left-1 -top-8 select-none text-6xl leading-none text-[#b4832e]/30">
“
</span>
<blockquote
className={`text-balance text-center text-[1.35rem] font-normal leading-snug text-[#1f231c] transition-all duration-[400ms] ${SPRING} ${
animating ? "scale-[0.98] opacity-0 blur-[3px]" : "scale-100 opacity-100 blur-none"
}`}
>
{t.body}
</blockquote>
<span aria-hidden className="pointer-events-none absolute -bottom-9 -right-0 select-none text-6xl leading-none text-[#b4832e]/30">
”
</span>
</div>
<figcaption
className={`mt-8 text-sm font-semibold text-[#8a6016] transition-all duration-500 ${SPRING} ${
animating ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100"
}`}
>
{t.author}
</figcaption>
</figure>
<div className="mt-8 flex max-w-lg flex-wrap items-center justify-center gap-2">
{TESTIMONIALS.map((item, i) => {
const isActive = active === i;
const showName = hovered === i && !isActive;
return (
<button
key={item.author}
type="button"
onClick={() => select(i)}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
aria-label={`Read testimonial from ${item.author}`}
aria-pressed={isActive}
className={`group relative flex cursor-pointer items-center rounded-full transition-all duration-500 ${SPRING} ${
isActive ? "bg-[#a84a2a] shadow-[0_8px_20px_rgba(0,0,0,0.12)]" : "bg-transparent hover:bg-[#22402f]/5"
} ${showName ? "py-1.5 pl-1.5 pr-4" : "p-1"}`}
>
<span
className={`relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs font-bold tracking-wide transition-all duration-500 ${
isActive ? "ring-2 ring-[#eef1ea]/60" : "bg-[#22402f] text-[#e8d9b0] ring-1 ring-transparent group-hover:bg-[#325a42]"
}`}
>
{initials(item.author)}
</span>
<span className={`grid transition-all duration-500 ${SPRING} ${showName ? "ml-2 grid-cols-[1fr] opacity-100" : "grid-cols-[0fr] opacity-0"}`}>
<span className="overflow-hidden">
<span className="block whitespace-nowrap text-sm font-semibold text-[#1f231c]">{item.author}</span>
</span>
</span>
</button>
);
})}
</div>
</div>
);
}
Playground
This place gave me a language for what I'd only ever felt.