{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonial-rotator",
  "type": "registry:ui",
  "title": "Testimonial Rotator",
  "description": "An editorial quote carousel: words blur in one by one, authors swap, and a pausable timer fills the active indicator.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/testimonial-rotator.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, type Variant, type Variants } from \"motion/react\";\n\nexport interface Testimonial {\n  id: string;\n  quote: string;\n  author: string;\n  role?: string;\n  /** Image URL. Initials are drawn when omitted. */\n  avatar?: string;\n}\n\nexport type TestimonialRotatorEffect = \"blur\" | \"rise\" | \"fade\";\n\nexport interface TestimonialRotatorProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  items: Testimonial[];\n  /** Advance automatically. Pauses on hover, focus, hidden tabs, offscreen and via the pause button. */\n  autoplay?: boolean;\n  /** Time each quote stays on screen, in ms. */\n  interval?: number;\n  /** How the words of a new quote arrive. */\n  effect?: TestimonialRotatorEffect;\n  /** Delay between words, in ms. */\n  stagger?: number;\n  align?: \"left\" | \"center\";\n  size?: \"md\" | \"lg\";\n  /** Draw the large accent quotation mark. */\n  quoteMark?: boolean;\n  /** Accent colour (any CSS colour). */\n  accent?: string;\n  /** Accessible name of the carousel. */\n  label?: string;\n  /** Index shown first. */\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n}\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction subscribeVisibility(onChange: () => void) {\n  document.addEventListener(\"visibilitychange\", onChange);\n  return () => document.removeEventListener(\"visibilitychange\", onChange);\n}\n\nfunction useDocumentHidden() {\n  return React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);\n}\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\nconst SWIPE_THRESHOLD = 48;\n\nconst WORD_HIDDEN: Record<TestimonialRotatorEffect, Variant> = {\n  blur: { opacity: 0, filter: \"blur(10px)\", y: 4 },\n  rise: { opacity: 0, y: \"0.55em\" },\n  fade: { opacity: 0 },\n};\n\nfunction initials(name: string) {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => Array.from(part)[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n}\n\nfunction Avatar({ item }: { item: Testimonial }) {\n  if (item.avatar) {\n    // eslint-disable-next-line @next/next/no-img-element -- shipped component must not depend on next/image\n    return <img src={item.avatar} alt=\"\" className=\"size-10 shrink-0 rounded-full object-cover\" />;\n  }\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-10 shrink-0 items-center justify-center rounded-full border border-[var(--tr-accent)]/40 font-mono text-[11px] tracking-[0.08em] text-zinc-900 dark:text-zinc-100\"\n      style={{ background: \"color-mix(in oklab, var(--tr-accent) 16%, transparent)\" }}\n    >\n      {initials(item.author)}\n    </span>\n  );\n}\n\n/**\n * An editorial testimonial carousel. Quotes arrive word by word, the author swaps underneath,\n * and an autoplay timer fills the active indicator. Rotation pauses on hover, focus, hidden\n * tabs, offscreen and via a visible pause button (WCAG 2.2.2). The live region is silent while\n * rotating and polite whenever the user is in control.\n */\nexport function TestimonialRotator({\n  items,\n  autoplay = true,\n  interval = 6000,\n  effect = \"blur\",\n  stagger = 30,\n  align = \"left\",\n  size = \"lg\",\n  quoteMark = true,\n  accent = \"#FF7448\",\n  label = \"Testimonials\",\n  defaultIndex = 0,\n  onIndexChange,\n  className = \"\",\n  style,\n  onKeyDown,\n  ...props\n}: TestimonialRotatorProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const documentHidden = useDocumentHidden();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const progressRef = React.useRef<HTMLSpanElement>(null);\n  const elapsedRef = React.useRef(0);\n  const swipeRef = React.useRef<{ x: number; y: number } | null>(null);\n  const regionId = React.useId();\n\n  const count = items.length;\n  const [index, setIndex] = React.useState(defaultIndex);\n  const [userPaused, setUserPaused] = React.useState(false);\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [inView, setInView] = React.useState(true);\n\n  const current = count ? ((index % count) + count) % count : 0;\n  const item = items[current];\n\n  // Reduced motion: no automatic rotation until the user explicitly starts it with the play button.\n  const [reducedStarted, setReducedStarted] = React.useState(false);\n  const rotationEnabled = autoplay && count > 1 && (reduceMotion ? reducedStarted : !userPaused);\n  const rotating = rotationEnabled && !hovered && !focused && inView && !documentHidden;\n\n  const go = (next: number) => {\n    if (!count) return;\n    const target = ((next % count) + count) % count;\n    elapsedRef.current = 0;\n    setIndex(target);\n    onIndexChange?.(target);\n  };\n\n  // Pause offscreen.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const observer = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)));\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, []);\n\n  // Autoplay clock: rAF accumulates elapsed time only while rotating, so pausing keeps progress.\n  React.useEffect(() => {\n    if (!rotating) return;\n    let last = performance.now();\n    let frame = requestAnimationFrame(function tick(now) {\n      elapsedRef.current += now - last;\n      last = now;\n      const p = Math.min(1, elapsedRef.current / Math.max(500, interval));\n      progressRef.current?.style.setProperty(\"--tr-p\", String(p));\n      if (p >= 1) {\n        elapsedRef.current = 0;\n        const next = (current + 1) % count;\n        setIndex(next);\n        onIndexChange?.(next);\n        return;\n      }\n      frame = requestAnimationFrame(tick);\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [rotating, interval, current, count, onIndexChange]);\n\n  // Sync the indicator after renders: elapsed progress while timed, a full bar when idle.\n  React.useLayoutEffect(() => {\n    const elapsed = elapsedRef.current;\n    const p = rotationEnabled || elapsed > 0 ? Math.min(1, elapsed / Math.max(500, interval)) : 1;\n    progressRef.current?.style.setProperty(\"--tr-p\", String(p));\n  });\n\n  if (!item) return null;\n\n  const toggleRotation = () => {\n    if (reduceMotion) setReducedStarted((s) => !s);\n    else setUserPaused((p) => !p);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      go(current + 1);\n    } else if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      go(current - 1);\n    }\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\") return;\n    swipeRef.current = { x: event.clientX, y: event.clientY };\n  };\n  const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    const start = swipeRef.current;\n    swipeRef.current = null;\n    if (!start) return;\n    const dx = event.clientX - start.x;\n    const dy = event.clientY - start.y;\n    if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) go(current + (dx < 0 ? 1 : -1));\n  };\n\n  const animated = !reduceMotion;\n  const words = item.quote.split(/\\s+/).filter(Boolean);\n  const wordVariants: Variants = {\n    hidden: WORD_HIDDEN[effect] ?? WORD_HIDDEN.blur,\n    visible: { opacity: 1, filter: \"blur(0px)\", y: 0, transition: { duration: 0.55, ease: EASE } },\n  };\n  const quoteVariants: Variants = {\n    hidden: {},\n    visible: { transition: { staggerChildren: Math.max(0, stagger) / 1000 } },\n    exit: { opacity: 0, filter: effect === \"blur\" ? \"blur(4px)\" : \"blur(0px)\", transition: { duration: 0.2, ease: \"easeIn\" } },\n  };\n\n  const centered = align === \"center\";\n  const quoteSize =\n    size === \"lg\"\n      ? \"text-[22px] leading-[1.25] sm:text-[30px] sm:leading-[1.2]\"\n      : \"text-lg leading-[1.35] sm:text-[22px] sm:leading-[1.3]\";\n  const quoteClass = `font-medium tracking-[-0.02em] text-zinc-900 dark:text-zinc-50 ${quoteSize}`;\n  const buttonClass =\n    \"inline-flex size-9 items-center justify-center rounded-full border border-zinc-900/10 text-zinc-700 outline-hidden transition-colors hover:border-zinc-900/30 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tr-accent)] dark:border-white/12 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white\";\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`relative w-full ${centered ? \"text-center\" : \"text-left\"} ${className}`}\n      style={{ [\"--tr-accent\" as string]: accent, ...style }}\n      onKeyDown={handleKeyDown}\n      onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocused(true)}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n      }}\n      {...props}\n    >\n      {quoteMark ? (\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 40 30\"\n          className={`mb-5 h-6 w-8 text-[var(--tr-accent)] ${centered ? \"mx-auto\" : \"\"}`}\n          fill=\"currentColor\"\n        >\n          <path d=\"M0 30V17.6C0 7.9 5.2 1.9 15.6 0l1.7 4.3C11.2 6 8.4 9.5 8.2 14.6H16V30H0Zm23.5 0V17.6c0-9.7 5.2-15.7 15.6-17.6L40 4.3c-5.4 1.7-8.2 5.2-8.4 10.3h7.8V30H23.5Z\" />\n        </svg>\n      ) : null}\n\n      {/* Silent while rotating; polite whenever the user is in control (APG carousel pattern). */}\n      <div\n        id={regionId}\n        aria-live={rotating ? \"off\" : \"polite\"}\n        aria-atomic=\"true\"\n        className=\"touch-pan-y select-none\"\n        onPointerDown={handlePointerDown}\n        onPointerUp={handlePointerUp}\n        onPointerCancel={() => (swipeRef.current = null)}\n      >\n        <div role=\"group\" aria-roledescription=\"slide\" aria-label={`${current + 1} of ${count}`}>\n          {/* Every quote is stacked invisibly in one grid cell, so the height never jumps between slides. */}\n          <div className=\"grid\">\n            {items.map((t) => (\n              <p key={t.id} aria-hidden=\"true\" className={`invisible [grid-area:1/1] ${quoteClass}`}>\n                {t.quote}\n              </p>\n            ))}\n            <blockquote className={`[grid-area:1/1] ${quoteClass}`}>\n              <p className=\"sr-only\">{item.quote}</p>\n              {animated ? (\n                <AnimatePresence mode=\"wait\" initial={false}>\n                  <motion.p\n                    key={item.id}\n                    aria-hidden=\"true\"\n                    variants={quoteVariants}\n                    initial=\"hidden\"\n                    animate=\"visible\"\n                    exit=\"exit\"\n                  >\n                    {words.map((word, i) => (\n                      <React.Fragment key={i}>\n                        {i > 0 ? \" \" : null}\n                        <motion.span variants={wordVariants} className=\"inline-block\">\n                          {word}\n                        </motion.span>\n                      </React.Fragment>\n                    ))}\n                  </motion.p>\n                </AnimatePresence>\n              ) : (\n                <p aria-hidden=\"true\">{item.quote}</p>\n              )}\n            </blockquote>\n          </div>\n\n          <div className={`mt-7 flex min-h-10 items-center gap-3 ${centered ? \"justify-center\" : \"\"}`}>\n            <AnimatePresence mode=\"wait\" initial={false}>\n              <motion.div\n                key={item.id}\n                className=\"flex items-center gap-3\"\n                initial={animated ? { opacity: 0, y: 6 } : false}\n                animate={{ opacity: 1, y: 0, transition: { duration: animated ? 0.4 : 0, ease: EASE, delay: animated ? 0.1 : 0 } }}\n                exit={animated ? { opacity: 0, y: -6, transition: { duration: 0.18 } } : { opacity: 0, transition: { duration: 0 } }}\n              >\n                <Avatar item={item} />\n                <span className=\"flex flex-col text-left\">\n                  <span className=\"text-sm font-medium text-zinc-900 dark:text-zinc-100\">{item.author}</span>\n                  {item.role ? (\n                    <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{item.role}</span>\n                  ) : null}\n                </span>\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </div>\n      </div>\n\n      {count > 1 ? (\n        <div\n          className={`mt-8 flex flex-wrap items-center gap-x-4 gap-y-3 border-t border-zinc-900/10 pt-4 dark:border-white/10 ${\n            centered ? \"justify-center\" : \"justify-between\"\n          }`}\n        >\n          <div role=\"group\" aria-label=\"Choose a testimonial\" className=\"flex items-center\">\n            {items.map((t, i) => {\n              const active = i === current;\n              return (\n                <button\n                  key={t.id}\n                  type=\"button\"\n                  aria-label={`Testimonial ${i + 1} of ${count}: ${t.author}`}\n                  aria-current={active ? \"true\" : undefined}\n                  aria-controls={regionId}\n                  onClick={() => go(i)}\n                  className=\"group/dot flex h-8 items-center px-1 outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--tr-accent)]\"\n                >\n                  <span\n                    className={`relative block h-[2px] overflow-hidden rounded-full bg-zinc-900/15 transition-[width] duration-300 group-hover/dot:bg-zinc-900/30 dark:bg-white/15 dark:group-hover/dot:bg-white/30 ${\n                      active ? \"w-10\" : \"w-4\"\n                    }`}\n                  >\n                    {active ? (\n                      <span\n                        key={current}\n                        ref={progressRef}\n                        className=\"absolute inset-0 origin-left bg-[var(--tr-accent)]\"\n                        // Server output depends only on props; the clock then drives --tr-p directly.\n                        style={{ transform: `scaleX(var(--tr-p, ${autoplay ? 0 : 1}))` }}\n                      />\n                    ) : null}\n                  </span>\n                </button>\n              );\n            })}\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <span aria-hidden=\"true\" className=\"mr-1 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400\">\n              {String(current + 1).padStart(2, \"0\")} / {String(count).padStart(2, \"0\")}\n            </span>\n            {autoplay ? (\n              <button\n                type=\"button\"\n                onClick={toggleRotation}\n                aria-label={rotationEnabled ? \"Pause automatic rotation\" : \"Start automatic rotation\"}\n                className={buttonClass}\n              >\n                {rotationEnabled ? (\n                  <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"currentColor\">\n                    <rect x=\"4\" y=\"3\" width=\"2.5\" height=\"10\" rx=\"0.5\" />\n                    <rect x=\"9.5\" y=\"3\" width=\"2.5\" height=\"10\" rx=\"0.5\" />\n                  </svg>\n                ) : (\n                  <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"currentColor\">\n                    <path d=\"M5 3.2v9.6a.5.5 0 0 0 .76.43l7.6-4.8a.5.5 0 0 0 0-.86l-7.6-4.8A.5.5 0 0 0 5 3.2Z\" />\n                  </svg>\n                )}\n              </button>\n            ) : null}\n            <button type=\"button\" onClick={() => go(current - 1)} aria-label=\"Previous testimonial\" aria-controls={regionId} className={buttonClass}>\n              <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n                <path d=\"M10 3.5 5.5 8l4.5 4.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n              </svg>\n            </button>\n            <button type=\"button\" onClick={() => go(current + 1)} aria-label=\"Next testimonial\" aria-controls={regionId} className={buttonClass}>\n              <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n                <path d=\"M6 3.5 10.5 8 6 12.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n              </svg>\n            </button>\n          </div>\n        </div>\n      ) : null}\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/testimonial-rotator"
}
