{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-effect",
  "type": "registry:ui",
  "title": "Text Effect",
  "description": "A Text Effect component for displaying animated text.",
  "files": [
    {
      "path": "src/registry/ui/text-effect.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst EASE_BACK = [0.34, 1.56, 0.64, 1] as const;\n\nconst EFFECTS = {\n  flip: {\n    initial: { y: \"40%\", opacity: 0, rotateX: 90 },\n    animate: { y: 0, opacity: 1, rotateX: 0, transition: { duration: 0.4, ease: EASE_OUT } },\n    exit: { y: \"-40%\", opacity: 0, rotateX: -90, transition: { duration: 0.3, ease: EASE_OUT } },\n  },\n  fade: {\n    initial: { opacity: 0, scale: 0.96, filter: \"blur(2px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\", transition: { duration: 0.4, ease: EASE_OUT } },\n    exit: { opacity: 0, scale: 1.04, filter: \"blur(2px)\", transition: { duration: 0.3, ease: EASE_OUT } },\n  },\n  slide: {\n    initial: { y: \"100%\", opacity: 0 },\n    animate: { y: 0, opacity: 1, transition: { duration: 0.45, ease: EASE_OUT } },\n    exit: { y: \"-100%\", opacity: 0, transition: { duration: 0.35, ease: EASE_OUT } },\n  },\n  scale: {\n    initial: { scale: 0.9, opacity: 0 },\n    animate: { scale: 1, opacity: 1, transition: { duration: 0.4, ease: EASE_BACK } },\n    exit: { scale: 0.9, opacity: 0, transition: { duration: 0.25, ease: EASE_OUT } },\n  },\n  rotate: {\n    initial: { rotate: -90, opacity: 0, scale: 0.6 },\n    animate: { rotate: 0, opacity: 1, scale: 1, transition: { duration: 0.5, ease: EASE_BACK } },\n    exit: { rotate: 90, opacity: 0, scale: 0.6, transition: { duration: 0.3, ease: EASE_OUT } },\n  },\n  bounce: {\n    initial: { y: \"-60%\", opacity: 0 },\n    animate: { y: 0, opacity: 1, transition: { type: \"spring\", stiffness: 400, damping: 15 } },\n    exit: { y: \"60%\", opacity: 0, transition: { duration: 0.2, ease: EASE_OUT } },\n  },\n  morph: {\n    initial: { opacity: 0, clipPath: \"inset(0 0 100% 0)\" },\n    animate: { opacity: 1, clipPath: \"inset(0 0 0% 0)\", transition: { duration: 0.6, ease: EASE_OUT } },\n    exit: { opacity: 0, clipPath: \"inset(100% 0 0 0)\", transition: { duration: 0.4, ease: EASE_OUT } },\n  },\n  blur: {\n    initial: { filter: \"blur(12px)\", opacity: 0 },\n    animate: { filter: \"blur(0px)\", opacity: 1, transition: { duration: 0.6, ease: EASE_OUT } },\n    exit: { filter: \"blur(12px)\", opacity: 0, transition: { duration: 0.4, ease: EASE_OUT } },\n  },\n} satisfies Record<string, Variants>;\n\n// Reduced motion keeps a plain crossfade.\nconst NO_MOTION: Variants = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } };\n\nconst ALIGN = {\n  start: \"justify-items-start\",\n  center: \"justify-items-center\",\n  end: \"justify-items-end\",\n} as const;\n\nexport type TextEffectName = keyof typeof EFFECTS;\nexport const TEXT_EFFECTS = Object.keys(EFFECTS) as TextEffectName[];\n\nexport type TextEffectProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  words: string[];\n  effect?: TextEffectName;\n  /** Milliseconds each word stays on screen. */\n  interval?: number;\n  /** Horizontal alignment of shorter words inside the widest word's box. */\n  align?: keyof typeof ALIGN;\n};\n\n/** Cycles through words in place. The box is sized to the longest word, so nothing around it shifts. */\nexport function TextEffect({\n  words,\n  effect = \"flip\",\n  interval = 3000,\n  align = \"start\",\n  className,\n  ...props\n}: TextEffectProps) {\n  const [index, setIndex] = useState(0);\n  const reduceMotion = useReducedMotion();\n  const count = words.length;\n\n  useEffect(() => {\n    if (count < 2) return;\n    const timer = setInterval(() => setIndex((i) => (i + 1) % count), interval);\n    return () => clearInterval(timer);\n  }, [count, interval]);\n\n  if (count === 0) return null;\n  const word = words[index % count];\n\n  return (\n    <span\n      data-slot=\"text-effect\"\n      className={cn(\"inline-grid align-bottom [perspective:600px]\", ALIGN[align], className)}\n      {...props}\n    >\n      <span className=\"sr-only\">{words.join(\", \")}</span>\n      {/* Every word occupies the same cell, reserving the widest one's width and height. */}\n      {words.map((w) => (\n        <span key={w} aria-hidden className=\"invisible [grid-area:1/1] whitespace-nowrap\">\n          {w}\n        </span>\n      ))}\n      <span aria-hidden className=\"relative overflow-hidden [grid-area:1/1] whitespace-nowrap\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={`${index}-${word}`}\n            variants={reduceMotion ? NO_MOTION : EFFECTS[effect]}\n            initial=\"initial\"\n            animate=\"animate\"\n            exit=\"exit\"\n            className=\"inline-block\"\n          >\n            {word}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n"
    }
  ],
  "dependencies": [
    "motion"
  ]
}
