{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "word-rotate",
  "title": "Word Rotate",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Cycles one word of a sentence with a vertical slide and blur while its width springs to the next word, so the rest of the line reflows smoothly. Screen readers hear one stable sentence.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/word-rotate.tsx",
      "content": "// Ballmac UI: Word Rotate. https://ui.ballmac.com/components/word-rotate\n\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useInView, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { duration, ease } from \"@/lib/ballmac/motion\"\n\ntype WordRotateProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** Words to cycle through, in order. */\n  words: string[]\n  /** Milliseconds each word stays before the next slides in. */\n  interval?: number\n  /** Stop on the current word. */\n  paused?: boolean\n  /** Class names for each word (color, gradient, weight). */\n  wordClassName?: string\n  /**\n   * What screen readers hear instead of the changing word. Defaults to the words joined with\n   * commas, so the sentence reads once, completely, without live announcements.\n   */\n  srText?: string\n}\n\nconst WIDTH_SPRING = { type: \"spring\", stiffness: 260, damping: 30 } as const\n\nfunction WordRotate({\n  words,\n  interval = 2400,\n  paused = false,\n  wordClassName,\n  srText,\n  className,\n  ...props\n}: WordRotateProps) {\n  const rootRef = React.useRef<HTMLSpanElement>(null)\n  const measureRef = React.useRef<HTMLSpanElement>(null)\n  const reduceMotion = useReducedMotion()\n  const inView = useInView(rootRef)\n  const [index, setIndex] = React.useState(0)\n  const [widths, setWidths] = React.useState<number[]>([])\n  const count = words.length\n  const current = index % Math.max(count, 1)\n\n  // Measure every word once (and again when fonts load or the size changes).\n  React.useEffect(() => {\n    const box = measureRef.current\n    if (!box) return\n    const measure = () => {\n      const next = Array.from(box.children, (child) => (child as HTMLElement).getBoundingClientRect().width)\n      setWidths((prev) => (prev.length === next.length && prev.every((w, i) => w === next[i]) ? prev : next))\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(box)\n    return () => observer.disconnect()\n  }, [words])\n\n  React.useEffect(() => {\n    if (paused || !inView || count < 2) return\n    const timer = window.setInterval(() => setIndex((i) => (i + 1) % count), interval)\n    return () => window.clearInterval(timer)\n  }, [paused, inView, count, interval])\n\n  const width = widths[current]\n  const word = words[current] ?? \"\"\n  const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, y: \"0.6em\", filter: \"blur(6px)\" }\n  const leaving = reduceMotion ? { opacity: 0 } : { opacity: 0, y: \"-0.6em\", filter: \"blur(6px)\" }\n\n  return (\n    <span ref={rootRef} data-slot=\"word-rotate\" className={cn(\"relative inline-block\", className)} {...props}>\n      <span className=\"sr-only\">{srText ?? words.join(\", \")}</span>\n      <motion.span\n        aria-hidden=\"true\"\n        data-slot=\"word-rotate-viewport\"\n        className=\"relative inline-block whitespace-nowrap [clip-path:inset(-0.3em_-0.12em)]\"\n        initial={false}\n        animate={width ? { width } : undefined}\n        transition={reduceMotion ? { duration: 0 } : WIDTH_SPRING}\n      >\n        {/* In-flow copy: gives the line its height and baseline. */}\n        <span className={cn(\"invisible\", wordClassName)}>{word}</span>\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${current}-${word}`}\n            data-slot=\"word-rotate-word\"\n            className={cn(\"absolute top-0 start-0\", wordClassName)}\n            initial={hidden}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={leaving}\n            transition={{ duration: duration.reveal, ease: ease.out }}\n          >\n            {word}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n      {/* Off-screen measurer with the same styles as the words. */}\n      <span\n        ref={measureRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none invisible absolute top-0 start-0 flex h-0 w-0 overflow-hidden whitespace-nowrap\"\n      >\n        {words.map((w, i) => (\n          <span key={i} className={cn(\"shrink-0\", wordClassName)}>\n            {w}\n          </span>\n        ))}\n      </span>\n    </span>\n  )\n}\n\nexport { WordRotate, type WordRotateProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/word-rotate.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "text",
      "headline",
      "rotate",
      "words",
      "hero",
      "typewriter",
      "motion"
    ],
    "version": "1.0.0",
    "updated": "2026-09-29",
    "ai": {
      "summary": "Place <WordRotate words={[\"faster\", \"safer\"]} /> inside a heading or paragraph; it inherits the font. Style the words with wordClassName (gradients, highlights). The accessible text is srText or the words joined with commas; the animated part is aria-hidden and not a live region.",
      "whenToUse": [
        "Hero headlines that name several audiences or benefits",
        "Short inline highlights in a tagline"
      ],
      "whenNotToUse": [
        "Information people must read in full (rotating text is easy to miss)",
        "Long phrases or more than about five options"
      ],
      "composesWith": [
        "gradient-text",
        "button"
      ],
      "a11y": [],
      "customization": [
        "interval in ms (default 2400), paused to hold the current word",
        "wordClassName for color, gradient (bg-clip-text) or a highlight background",
        "srText to set exactly what screen readers hear",
        "Pauses while off-screen; reduced motion swaps words with a plain fade"
      ]
    },
    "examples": [
      "word-rotate-demo",
      "word-rotate-inline"
    ],
    "url": "https://ui.ballmac.com/components/word-rotate"
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}