{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flip-words",
  "type": "registry:ui",
  "title": "Flip Words",
  "description": "A headline word that flips through a list. Each replacement enters letter-by-letter out of an 8px blur while the outgoing one drifts up, blurs and scales away.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/aceternity/flip-words.tsx",
      "target": "components/ui/aceternity/flip-words.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\n\n// @interlace/flip-words v1.1.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/flip-words\n// What changed since: https://ds.interlace.tools/c/flip-words#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — FlipWords\n *\n * A headline word that flips through a list. Each replacement enters\n * letter-by-letter out of an 8px blur while the outgoing one drifts up, blurs\n * and scales away.\n *\n * It runs itself on a `duration` dwell timer (3000ms by default), or you hand\n * it `index` + `onIndexChange` and drive it yourself.\n *\n * ## Provenance\n *\n * Our reimplementation of the Aceternity UI `FlipWords`. What is ours: a\n * numeric index with a controlled mode — upstream is uncontrolled-only and\n * derives its position from `words.indexOf(currentWord)`, which misbehaves the\n * moment the list contains a duplicate; `currentColor` instead of a hard-coded\n * `text-neutral-900 dark:text-neutral-100` pair; `pauseOnHover`; and the\n * reduced-motion branch below.\n *\n * ## Anatomy\n *\n *   FlipWords                        (span — data-slot=\"flip-words\")\n *     ├─ span.sr-only aria-live=polite   (the announced text)\n *     └─ span[data-slot=flip-words-static]  (under reduce)\n *        | motion.span[data-slot=flip-words-word]\n *            └─ span per word\n *                └─ motion.span per letter\n *\n * The visible word is `aria-hidden` in BOTH branches. Everything a screen\n * reader gets comes from the polite live region, so the letter-by-letter\n * staging is never spelled out.\n *\n * ## Motion\n *\n * JS-driven — `motion/react` enter/exit variants plus a `setTimeout` cycle —\n * so the CSS reset in `styles/preflight.css` reaches none of it. It is gated\n * in JS, at both levels: `cyclingPaused` includes `reducedMotion`, so the\n * timer never fires, and the render swaps the entire `AnimatePresence` subtree\n * for a plain `<span>` carrying the active word. Under\n * `prefers-reduced-motion: reduce` you get one stable, readable word and no\n * animation object is ever constructed.\n *\n * `pauseOnHover` is documented as a no-op under reduce for the same reason:\n * there is nothing left to pause.\n *\n * ## Layout\n *\n * The outgoing word animates to `position: absolute`, so the span does not\n * reserve width for the longest entry. A list with very different word lengths\n * will reflow the line around it as it cycles.\n */\n\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\ninterface FlipWordsProps\n  extends Omit<ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /**\n   * Ordered list of words (or short phrases) to cycle through. Each entry is\n   * animated in word-by-word and letter-by-letter, then animated out before the\n   * next entry enters.\n   */\n  words: string[];\n  /**\n   * Time in milliseconds a word stays on screen before flipping to the next\n   * one. The enter/exit animation runs on top of this dwell time.\n   * @default 3000\n   */\n  duration?: number;\n  /**\n   * The index of the word shown on first render when the component is\n   * uncontrolled. Ignored when `index` is provided.\n   * @default 0\n   */\n  defaultIndex?: number;\n  /**\n   * Controlled index of the active word. When provided, the component will not\n   * advance on its own — drive it from the parent and pair with\n   * `onIndexChange`.\n   */\n  index?: number;\n  /**\n   * Called with the next index whenever the active word changes, in both\n   * controlled and uncontrolled modes.\n   * @default undefined\n   */\n  onIndexChange?: (index: number, details: { word: string }) => void;\n  /**\n   * Pause cycling while the pointer is over the component, giving readers time\n   * to finish the current word. Has no effect under reduced motion (cycling is\n   * already disabled).\n   * @default false\n   */\n  pauseOnHover?: boolean;\n  /**\n   * Stagger between each word in a multi-word entry, in seconds.\n   * @default 0.3\n   */\n  wordStagger?: number;\n  /**\n   * Stagger between each letter within a word, in seconds.\n   * @default 0.05\n   */\n  letterStagger?: number;\n  /**\n   * Stable selector hook for end-to-end tests. Required so consumers never rely\n   * on a silent default; sub-parts derive `{value}-word` / `{value}-letter`.\n   */\n  \"data-testid\": string;\n}\n\nconst SPRING = { type: \"spring\", stiffness: 100, damping: 10 } as const;\n\n/**\n * FlipWords — an animated headline word that flips through a list, revealing\n * each replacement letter-by-letter with a soft blur-and-rise.\n *\n * Motion contract: under `prefers-reduced-motion: reduce` the component renders\n * the active word statically with no enter/exit animation and stops cycling, so\n * reduced-motion users get a stable, readable headline (WCAG 2.3.3).\n *\n * Color is inherited via `currentColor` by default — set the text color on a\n * parent (or pass a Tailwind text token through `className`) so the flipping\n * word matches its surrounding type. The component never ships a color literal.\n */\nexport function FlipWords({\n  words,\n  duration = 3000,\n  defaultIndex = 0,\n  index: controlledIndex,\n  onIndexChange,\n  pauseOnHover = false,\n  wordStagger = 0.3,\n  letterStagger = 0.05,\n  className,\n  \"data-testid\": testId,\n  ...props\n}: FlipWordsProps) {\n  const reducedMotion = useReducedMotion();\n  const liveRegionId = useId();\n\n  const isControlled = controlledIndex !== undefined;\n  const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex);\n  const [hovered, setHovered] = useState(false);\n\n  // Clamp so an out-of-range index (controlled or default) never renders\n  // `undefined`. Empty `words` falls back to an empty string.\n  const activeIndex =\n    words.length === 0\n      ? 0\n      : ((isControlled ? controlledIndex : uncontrolledIndex) % words.length +\n          words.length) %\n        words.length;\n  const currentWord = words[activeIndex] ?? \"\";\n\n  const advance = useCallback(() => {\n    if (words.length === 0) return;\n    const next = (activeIndex + 1) % words.length;\n    onIndexChange?.(next, { word: words[next] ?? \"\" });\n    if (!isControlled) setUncontrolledIndex(next);\n  }, [activeIndex, isControlled, onIndexChange, words]);\n\n  // Cycle on a timer unless the parent controls the index, the user prefers\n  // reduced motion, or the pointer is hovering with `pauseOnHover`.\n  const cyclingPaused =\n    isControlled || reducedMotion || (pauseOnHover && hovered);\n\n  useEffect(() => {\n    if (cyclingPaused || words.length <= 1) return;\n    const timer = setTimeout(advance, duration);\n    return () => clearTimeout(timer);\n  }, [advance, cyclingPaused, duration, words.length, activeIndex]);\n\n  const hoverHandlers = useMemo(\n    () =>\n      pauseOnHover\n        ? {\n            onPointerEnter: () => setHovered(true),\n            onPointerLeave: () => setHovered(false),\n          }\n        : {},\n    [pauseOnHover],\n  );\n\n  const segments = currentWord.split(\" \");\n\n  return (\n    <span\n      {...props}\n      {...hoverHandlers}\n      data-slot=\"flip-words\"\n      data-testid={testId}\n      className={cn(\n        \"relative inline-block text-left text-current\",\n        className,\n      )}\n    >\n      {/* Polite live region so screen readers announce each new word. */}\n      <span id={liveRegionId} aria-live=\"polite\" className=\"sr-only\">\n        {currentWord}\n      </span>\n\n      {reducedMotion ? (\n        <span data-slot=\"flip-words-static\" aria-hidden=\"true\">\n          {currentWord}\n        </span>\n      ) : (\n        <AnimatePresence mode=\"wait\">\n          <motion.span\n            key={`${currentWord}-${activeIndex}`}\n            data-slot=\"flip-words-word\"\n            data-testid={`${testId}-word`}\n            aria-hidden=\"true\"\n            initial={{ opacity: 0, y: 10 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{\n              opacity: 0,\n              y: -40,\n              x: 40,\n              filter: \"blur(8px)\",\n              scale: 2,\n              position: \"absolute\",\n            }}\n            transition={SPRING}\n            className=\"relative z-10 inline-block\"\n          >\n            {segments.map((segment, segmentIndex) => (\n              <span\n                key={`${segment}-${segmentIndex}`}\n                className=\"inline-block whitespace-nowrap\"\n              >\n                {Array.from(segment).map((letter, letterIndex) => (\n                  <motion.span\n                    key={`${letter}-${letterIndex}`}\n                    initial={{ opacity: 0, y: 10, filter: \"blur(8px)\" }}\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    transition={{\n                      delay:\n                        segmentIndex * wordStagger +\n                        letterIndex * letterStagger,\n                      duration: 0.2,\n                    }}\n                    className=\"inline-block\"\n                  >\n                    {letter}\n                  </motion.span>\n                ))}\n                {segmentIndex < segments.length - 1 ? (\n                  <span className=\"inline-block\">&nbsp;</span>\n                ) : null}\n              </span>\n            ))}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.1.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/flip-words\n\nInstalled to `components/ui/aceternity/flip-words.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/aceternity/flip-words';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/flip-words\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
