{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"testimonials","type":"registry:component","title":"Testimonials","description":"One quote, chosen by its people: faces step forward, the words cross-fade through a blur, a ring keeps time.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/testimonials.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * TESTIMONIALS: one quote, chosen by its people\n *\n *   choose    a row of faces; the chosen one comes forward and\n *             the rest step back\n *   change    the quote's height eases to the new words while\n *             they cross-fade through a soft blur; the name\n *             morphs letter by letter\n *   autoplay  a ring draws itself around the chosen face as the\n *             timer; when it closes, the next person speaks.\n *             Pointing at it pauses\n *\n * The faces are a radio group: arrow keys move between people.\n * ───────────────────────────────────────────────────────── */\n\nexport interface Testimonial {\n  quote: string;\n  name: string;\n  role?: string;\n  /** A photo URL; without one, tinted initials. */\n  avatar?: string;\n}\n\nexport interface TestimonialsProps extends React.HTMLAttributes<HTMLElement> {\n  items: Testimonial[];\n  /** Move to the next person every this many ms. */\n  autoplay?: number;\n  defaultIndex?: number;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst MORPH = 520;\nconst SIZE = 36;\nconst RING = 2 * Math.PI * (SIZE / 2 + 4);\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction hue(text: string) {\n  let h = 0;\n  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) % 360;\n  return h;\n}\n\nfunction Face({ item }: { item: Testimonial }) {\n  const [failed, setFailed] = React.useState(false);\n  if (item.avatar && !failed) return <img src={item.avatar} alt=\"\" onError={() => setFailed(true)} className=\"size-full rounded-full object-cover\" />;\n  const h = hue(item.name);\n  const initials = item.name\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .slice(0, 2)\n    .join(\"\");\n  return (\n    <span\n      className=\"flex size-full items-center justify-center rounded-full text-[12px] font-semibold\"\n      style={{ background: `color-mix(in oklab, oklch(0.62 0.13 ${h}) 18%, var(--background))`, color: `oklch(0.5 0.12 ${h})` }}\n    >\n      {initials}\n    </span>\n  );\n}\n\nfunction Quote({ text, leaving, reduced, quoteRef }: { text: string; leaving: boolean; reduced: boolean; quoteRef?: React.Ref<HTMLQuoteElement> }) {\n  const ref = React.useRef<HTMLQuoteElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || reduced) return;\n    const animation = leaving\n      ? el.animate(\n          [\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n            { opacity: 0, filter: \"blur(8px)\", transform: \"translateY(-8px)\" },\n          ],\n          { duration: MORPH * 0.55, easing: EASE, fill: \"forwards\" }\n        )\n      : el.animate(\n          [\n            { opacity: 0, filter: \"blur(8px)\", transform: \"translateY(10px)\" },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          { duration: MORPH, easing: EASE, delay: 90, fill: \"backwards\" }\n        );\n    return () => animation.cancel();\n  }, [leaving, reduced]);\n  return (\n    <blockquote\n      ref={(el) => {\n        ref.current = el;\n        if (typeof quoteRef === \"function\") quoteRef(el);\n        else if (quoteRef) (quoteRef as React.MutableRefObject<HTMLQuoteElement | null>).current = el;\n      }}\n      aria-hidden={leaving || undefined}\n      className=\"absolute inset-x-0 top-0 text-balance text-[22px] font-medium leading-[1.35] tracking-[-0.02em] text-foreground\"\n    >\n      <span aria-hidden=\"true\" className=\"text-muted-foreground/60\">\n        “\n      </span>\n      {text}\n      <span aria-hidden=\"true\" className=\"text-muted-foreground/60\">\n        ”\n      </span>\n    </blockquote>\n  );\n}\n\nexport function Testimonials({ items, autoplay, defaultIndex = 0, className = \"\", ...props }: TestimonialsProps) {\n  const reduced = useReducedMotion();\n  const [index, setIndex] = React.useState(defaultIndex);\n  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);\n  const [height, setHeight] = React.useState<number | null>(null);\n  const [paused, setPaused] = React.useState(false);\n  const [cycle, setCycle] = React.useState(0);\n  const quoteRef = React.useRef<HTMLQuoteElement | null>(null);\n  const faceRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const counter = React.useRef(0);\n  const current = items[index];\n\n  const choose = (next: number, focus = false) => {\n    const i = (next + items.length) % items.length;\n    setCycle((c) => c + 1);\n    if (focus) faceRefs.current[i]?.focus();\n    if (i === index) return;\n    setLeaving((l) => [...l, { text: items[index].quote, key: ++counter.current }]);\n    setIndex(i);\n  };\n\n  React.useEffect(() => {\n    if (!leaving.length) return;\n    const timer = window.setTimeout(() => setLeaving([]), MORPH);\n    return () => window.clearTimeout(timer);\n  }, [leaving]);\n\n  // The quote area takes the height of the words now showing.\n  React.useLayoutEffect(() => {\n    const el = quoteRef.current;\n    if (!el) return;\n    const measure = () => setHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [index]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const moves: Record<string, number> = { ArrowRight: index + 1, ArrowDown: index + 1, ArrowLeft: index - 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 };\n    if (!(e.key in moves)) return;\n    e.preventDefault();\n    choose(moves[e.key], true);\n  };\n\n  const playing = Boolean(autoplay) && !reduced && !paused;\n  if (!current) return null;\n\n  return (\n    <figure\n      className={`w-full ${className}`}\n      onPointerEnter={() => setPaused(true)}\n      onPointerLeave={() => setPaused(false)}\n      onFocus={() => setPaused(true)}\n      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setPaused(false)}\n      {...props}\n    >\n      <div\n        className=\"relative\"\n        aria-live={autoplay ? \"off\" : \"polite\"}\n        style={{ height: height ?? undefined, transition: reduced || height === null ? \"none\" : `height ${MORPH}ms ${EASE}` }}\n      >\n        {leaving.map((l) => (\n          <Quote key={l.key} text={l.text} leaving reduced={reduced} />\n        ))}\n        <Quote key={`q${index}`} text={current.quote} leaving={false} reduced={reduced || !leaving.length} quoteRef={quoteRef} />\n      </div>\n\n      <figcaption className=\"mt-6 flex items-baseline gap-2 text-[13.5px]\">\n        <TextMorph className=\"font-medium text-foreground\">{current.name}</TextMorph>\n        {current.role && (\n          <span key={current.role} className=\"text-muted-foreground animate-[ui-fade-in_400ms_ease-out_120ms_both] motion-reduce:animate-none\">\n            {current.role}\n          </span>\n        )}\n      </figcaption>\n\n      <div role=\"radiogroup\" aria-label=\"Choose a testimonial\" onKeyDown={onKeyDown} className=\"mt-5 flex items-center gap-3\">\n        {items.map((item, i) => {\n          const selected = i === index;\n          return (\n            <button\n              key={item.name}\n              ref={(el) => {\n                faceRefs.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={selected}\n              aria-label={`${item.name}${item.role ? `, ${item.role}` : \"\"}`}\n              tabIndex={selected ? 0 : -1}\n              onClick={() => choose(i)}\n              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\"\n              style={{ width: SIZE, height: SIZE }}\n            >\n              <span\n                className=\"block size-full overflow-hidden rounded-full transition-[opacity,transform,filter] duration-500\"\n                style={{\n                  opacity: selected ? 1 : 0.45,\n                  transform: selected ? \"scale(1)\" : \"scale(0.88)\",\n                  filter: selected ? \"none\" : \"grayscale(0.6)\",\n                  transitionTimingFunction: EASE,\n                }}\n              >\n                <Face item={item} />\n              </span>\n              {/* The ring: full when chosen, or drawing itself as the autoplay timer. */}\n              <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 }}>\n                <circle\n                  key={selected ? `${index}-${cycle}` : \"idle\"}\n                  cx={(SIZE + 10) / 2}\n                  cy={(SIZE + 10) / 2}\n                  r={SIZE / 2 + 4}\n                  fill=\"none\"\n                  strokeWidth=\"1.25\"\n                  strokeLinecap=\"round\"\n                  className=\"stroke-foreground transition-opacity duration-300\"\n                  strokeDasharray={RING}\n                  onAnimationEnd={() => selected && choose(index + 1)}\n                  style={{\n                    opacity: selected ? 1 : 0,\n                    strokeDashoffset: selected && !(autoplay && !reduced) ? 0 : RING,\n                    ...(selected && autoplay && !reduced\n                      ? ({ \"--ui-ring\": `${RING}`, animation: `ui-ring-fill ${autoplay}ms linear forwards`, animationPlayState: playing ? \"running\" : \"paused\" } as React.CSSProperties)\n                      : {}),\n                  }}\n                />\n              </svg>\n            </button>\n          );\n        })}\n      </div>\n    </figure>\n  );\n}\n"}],"css":{"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}},"@keyframes ui-ring-fill":{"from":{"stroke-dashoffset":"var(--ui-ring)"},"to":{"stroke-dashoffset":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/testimonials"}