Testimonials

One quote, chosen by its people: faces step forward, the words cross-fade through a blur, a ring keeps time.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/testimonials.json

Overview

A carousel of quote cards asks people to read three things at once. This shows one quote and the people behind it. Choose a face and it steps forward while the others step back; the quote’s height eases to the new words as they cross-fade through a soft blur, and the name morphs letter by letter. With autoplay, a thin ring draws itself around the chosen face as the timer and hands over when it closes, pausing whenever you point at it.

States

choose
The chosen face scales to full and full colour; the others step back to 88%, faded and greyed.
change
Old words lift and blur away, new ones rise in (520ms) while the block eases to their height; the name morphs and the role fades in.
autoplay
A 1.25px ring draws around the chosen face over the interval, then the next person speaks. Pointer or focus inside pauses it.

Usage

import { Testimonials } from "@/components/testimonials";

<Testimonials
  autoplay={5200}
  items={[{ quote: "It looks like we hired a studio.", name: "Daniel Okafor", role: "Solo maker", avatar: "/daniel.jpg" }]}
/>

Works with

In a proof section

Feed it the quotes you have; photos are optional (tinted initials stand in).

import { Testimonials } from "@/components/testimonials";

export default async function Proof() {
  const quotes = await getApprovedTestimonials(); // your CMS or a JSON file

  return (
    <section className="mx-auto max-w-xl py-24">
      <Testimonials
        autoplay={6000}
        items={quotes.map((q) => ({ quote: q.text, name: q.author, role: q.company, avatar: q.photoUrl }))}
      />
    </section>
  );
}

Props

PropTypeDescription
items{ quote; name; role?; avatar? }[]Each person and what they said. Without an avatar, initials tinted from the name.
autoplaynumberMove to the next person every this many ms, with the ring as the timer. Off with reduced motion.
defaultIndexnumberdefault 0Who speaks first.

Accessibility and motion

  • The faces are a radio group labelled with each name and role; arrow keys, Home and End move between people.
  • Without autoplay the quote is a polite live region, so a new choice is read out; with autoplay it stays quiet.
  • The ring timer is a CSS animation that pauses with play-state, so nothing re-renders while it runs.
  • Installs Text morph alongside it. With reduced motion, quotes change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

The CLI also installs Text morph.

Source

›Show the full source of testimonials.tsx (253 lines)
"use client";

import * as React from "react";
import { TextMorph } from "./text-morph";

/* ─────────────────────────────────────────────────────────
 * TESTIMONIALS: one quote, chosen by its people
 *
 *   choose    a row of faces; the chosen one comes forward and
 *             the rest step back
 *   change    the quote's height eases to the new words while
 *             they cross-fade through a soft blur; the name
 *             morphs letter by letter
 *   autoplay  a ring draws itself around the chosen face as the
 *             timer; when it closes, the next person speaks.
 *             Pointing at it pauses
 *
 * The faces are a radio group: arrow keys move between people.
 * ───────────────────────────────────────────────────────── */

export interface Testimonial {
  quote: string;
  name: string;
  role?: string;
  /** A photo URL; without one, tinted initials. */
  avatar?: string;
}

export interface TestimonialsProps extends React.HTMLAttributes<HTMLElement> {
  items: Testimonial[];
  /** Move to the next person every this many ms. */
  autoplay?: number;
  defaultIndex?: number;
}

const EASE = "cubic-bezier(0.16,1,0.3,1)";
const MORPH = 520;
const SIZE = 36;
const RING = 2 * Math.PI * (SIZE / 2 + 4);

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

function hue(text: string) {
  let h = 0;
  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) % 360;
  return h;
}

function Face({ item }: { item: Testimonial }) {
  const [failed, setFailed] = React.useState(false);
  if (item.avatar && !failed) return <img src={item.avatar} alt="" onError={() => setFailed(true)} className="size-full rounded-full object-cover" />;
  const h = hue(item.name);
  const initials = item.name
    .split(/\s+/)
    .map((w) => w[0])
    .slice(0, 2)
    .join("");
  return (
    <span
      className="flex size-full items-center justify-center rounded-full text-[12px] font-semibold"
      style={{ background: `color-mix(in oklab, oklch(0.62 0.13 ${h}) 18%, var(--background))`, color: `oklch(0.5 0.12 ${h})` }}
    >
      {initials}
    </span>
  );
}

function Quote({ text, leaving, reduced, quoteRef }: { text: string; leaving: boolean; reduced: boolean; quoteRef?: React.Ref<HTMLQuoteElement> }) {
  const ref = React.useRef<HTMLQuoteElement>(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el || reduced) return;
    const animation = leaving
      ? el.animate(
          [
            { opacity: 1, filter: "blur(0px)", transform: "none" },
            { opacity: 0, filter: "blur(8px)", transform: "translateY(-8px)" },
          ],
          { duration: MORPH * 0.55, easing: EASE, fill: "forwards" }
        )
      : el.animate(
          [
            { opacity: 0, filter: "blur(8px)", transform: "translateY(10px)" },
            { opacity: 1, filter: "blur(0px)", transform: "none" },
          ],
          { duration: MORPH, easing: EASE, delay: 90, fill: "backwards" }
        );
    return () => animation.cancel();
  }, [leaving, reduced]);
  return (
    <blockquote
      ref={(el) => {
        ref.current = el;
        if (typeof quoteRef === "function") quoteRef(el);
        else if (quoteRef) (quoteRef as React.MutableRefObject<HTMLQuoteElement | null>).current = el;
      }}
      aria-hidden={leaving || undefined}
      className="absolute inset-x-0 top-0 text-balance text-[22px] font-medium leading-[1.35] tracking-[-0.02em] text-foreground"
    >
      <span aria-hidden="true" className="text-muted-foreground/60">
        “
      </span>
      {text}
      <span aria-hidden="true" className="text-muted-foreground/60">
        ”
      </span>
    </blockquote>
  );
}

export function Testimonials({ items, autoplay, defaultIndex = 0, className = "", ...props }: TestimonialsProps) {
  const reduced = useReducedMotion();
  const [index, setIndex] = React.useState(defaultIndex);
  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);
  const [height, setHeight] = React.useState<number | null>(null);
  const [paused, setPaused] = React.useState(false);
  const [cycle, setCycle] = React.useState(0);
  const quoteRef = React.useRef<HTMLQuoteElement | null>(null);
  const faceRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const counter = React.useRef(0);
  const current = items[index];

  const choose = (next: number, focus = false) => {
    const i = (next + items.length) % items.length;
    setCycle((c) => c + 1);
    if (focus) faceRefs.current[i]?.focus();
    if (i === index) return;
    setLeaving((l) => [...l, { text: items[index].quote, key: ++counter.current }]);
    setIndex(i);
  };

  React.useEffect(() => {
    if (!leaving.length) return;
    const timer = window.setTimeout(() => setLeaving([]), MORPH);
    return () => window.clearTimeout(timer);
  }, [leaving]);

  // The quote area takes the height of the words now showing.
  React.useLayoutEffect(() => {
    const el = quoteRef.current;
    if (!el) return;
    const measure = () => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [index]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const moves: Record<string, number> = { ArrowRight: index + 1, ArrowDown: index + 1, ArrowLeft: index - 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 };
    if (!(e.key in moves)) return;
    e.preventDefault();
    choose(moves[e.key], true);
  };

  const playing = Boolean(autoplay) && !reduced && !paused;
  if (!current) return null;

  return (
    <figure
      className={`w-full ${className}`}
      onPointerEnter={() => setPaused(true)}
      onPointerLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setPaused(false)}
      {...props}
    >
      <div
        className="relative"
        aria-live={autoplay ? "off" : "polite"}
        style={{ height: height ?? undefined, transition: reduced || height === null ? "none" : `height ${MORPH}ms ${EASE}` }}
      >
        {leaving.map((l) => (
          <Quote key={l.key} text={l.text} leaving reduced={reduced} />
        ))}
        <Quote key={`q${index}`} text={current.quote} leaving={false} reduced={reduced || !leaving.length} quoteRef={quoteRef} />
      </div>

      <figcaption className="mt-6 flex items-baseline gap-2 text-[13.5px]">
        <TextMorph className="font-medium text-foreground">{current.name}</TextMorph>
        {current.role && (
          <span key={current.role} className="text-muted-foreground animate-[ui-fade-in_400ms_ease-out_120ms_both] motion-reduce:animate-none">
            {current.role}
          </span>
        )}
      </figcaption>

      <div role="radiogroup" aria-label="Choose a testimonial" onKeyDown={onKeyDown} className="mt-5 flex items-center gap-3">
        {items.map((item, i) => {
          const selected = i === index;
          return (
            <button
              key={item.name}
              ref={(el) => {
                faceRefs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={selected}
              aria-label={`${item.name}${item.role ? `, ${item.role}` : ""}`}
              tabIndex={selected ? 0 : -1}
              onClick={() => choose(i)}
              className="relative rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-4 focus-visible:ring-offset-background"
              style={{ width: SIZE, height: SIZE }}
            >
              <span
                className="block size-full overflow-hidden rounded-full transition-[opacity,transform,filter] duration-500"
                style={{
                  opacity: selected ? 1 : 0.45,
                  transform: selected ? "scale(1)" : "scale(0.88)",
                  filter: selected ? "none" : "grayscale(0.6)",
                  transitionTimingFunction: EASE,
                }}
              >
                <Face item={item} />
              </span>
              {/* The ring: full when chosen, or drawing itself as the autoplay timer. */}
              <svg aria-hidden="true" viewBox={`0 0 ${SIZE + 10} ${SIZE + 10}`} className="pointer-events-none absolute -inset-[5px] -rotate-90" style={{ width: SIZE + 10, height: SIZE + 10 }}>
                <circle
                  key={selected ? `${index}-${cycle}` : "idle"}
                  cx={(SIZE + 10) / 2}
                  cy={(SIZE + 10) / 2}
                  r={SIZE / 2 + 4}
                  fill="none"
                  strokeWidth="1.25"
                  strokeLinecap="round"
                  className="stroke-foreground transition-opacity duration-300"
                  strokeDasharray={RING}
                  onAnimationEnd={() => selected && choose(index + 1)}
                  style={{
                    opacity: selected ? 1 : 0,
                    strokeDashoffset: selected && !(autoplay && !reduced) ? 0 : RING,
                    ...(selected && autoplay && !reduced
                      ? ({ "--ui-ring": `${RING}`, animation: `ui-ring-fill ${autoplay}ms linear forwards`, animationPlayState: playing ? "running" : "paused" } as React.CSSProperties)
                      : {}),
                  }}
                />
              </svg>
            </button>
          );
        })}
      </div>
    </figure>
  );
}

Registry item: /r/testimonials.json