{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stagger-chars",
  "type": "registry:ui",
  "title": "Stagger Chars",
  "description": "An animated text component that creates a smooth, staggered character animation effect when hovered.",
  "author": "Ahdeetai <https://aditya.is-cool.dev>",
  "registryDependencies": [],
  "dependencies": ["motion", "clsx", "tailwind-merge"],
  "files": [
    {
      "type": "registry:ui",
      "path": "components/ui/stagger-chars.tsx",
      "content": "'use client';\nimport * as React from 'react';\nimport {\n  AnimatePresence,\n  motion,\n  type Variants,\n  useReducedMotion,\n  type Easing,\n} from 'motion/react';\nimport { cn } from '@/lib/utils';\n\ninterface StaggerCharsProps {\n  text: string;\n  hoverText?: string;\n  delay?: number;\n  duration?: number;\n  className?: string;\n  hoverClassName?: string;\n  direction?: 'up' | 'down' | 'alternate';\n  easing?: Easing;\n  disabled?: boolean;\n  onAnimationStart?: () => void;\n  onAnimationComplete?: () => void;\n}\n\nconst useProcessedChars = (text: string, hoverText?: string) =>\n  React.useMemo(() => {\n    const base = text.split('');\n    const hover = (hoverText ?? text).split('');\n    const max = Math.max(base.length, hover.length);\n\n    return {\n      safeBase: Array.from({ length: max }, (_, i) => base[i] ?? ' '),\n      safeHover: Array.from({ length: max }, (_, i) => hover[i] ?? ' '),\n    };\n  }, [text, hoverText]);\n\nconst useIsTouchDevice = () => {\n  const [isTouch, setIsTouch] = React.useState(false);\n\n  React.useEffect(() => {\n    const check = () =>\n      setIsTouch('ontouchstart' in window || navigator.maxTouchPoints > 0);\n\n    check();\n    window.addEventListener('resize', check);\n    return () => window.removeEventListener('resize', check);\n  }, []);\n\n  return isTouch;\n};\n\nconst getInitialY = (\n  direction: StaggerCharsProps['direction'],\n  isEven: boolean,\n) => {\n  switch (direction) {\n    case 'up':\n      return '0%';\n    case 'down':\n      return '-50%';\n    case 'alternate':\n    default:\n      return isEven ? '-50%' : '0%';\n  }\n};\n\nconst getTargetY = (\n  direction: StaggerCharsProps['direction'],\n  isEven: boolean,\n) => {\n  switch (direction) {\n    case 'up':\n      return '-50%';\n    case 'down':\n      return '0%';\n    case 'alternate':\n    default:\n      return isEven ? '0%' : '-50%';\n  }\n};\n\nconst StaggerChars = React.memo<StaggerCharsProps>(\n  ({\n    text,\n    hoverText,\n    hoverClassName,\n    delay = 0.05,\n    duration = 1,\n    className,\n    direction = 'alternate',\n    easing = [0.22, 1, 0.36, 1],\n    disabled = false,\n    onAnimationStart,\n    onAnimationComplete,\n  }) => {\n    const { safeBase, safeHover } = useProcessedChars(text, hoverText);\n    const prefersReducedMotion = useReducedMotion();\n    const isTouchDevice = useIsTouchDevice();\n\n    const [isHovered, setIsHovered] = React.useState(false);\n    const [isAutoAnimating, setIsAutoAnimating] = React.useState(false);\n    const intervalRef = React.useRef<NodeJS.Timeout | undefined>(undefined);\n\n    React.useEffect(() => {\n      if (!isTouchDevice || disabled) return;\n      const timeout = setTimeout(() => {\n        setIsAutoAnimating(true);\n        onAnimationStart?.();\n        intervalRef.current = setInterval(\n          () => setIsAutoAnimating((prev) => !prev),\n          2000,\n        );\n      }, 1000);\n\n      return () => {\n        clearTimeout(timeout);\n        if (intervalRef.current) clearInterval(intervalRef.current);\n      };\n    }, [isTouchDevice, disabled, onAnimationStart]);\n\n    const containerVariants: Variants = {\n      initial: {},\n      hover: {\n        transition: {\n          staggerChildren: prefersReducedMotion ? 0 : delay,\n        },\n      },\n      exit: {},\n    };\n\n    const stackVariants: Variants = {\n      initial: ({ isEven }: { index: number; isEven: boolean }) =>\n        prefersReducedMotion\n          ? { y: '0%' }\n          : { y: getInitialY(direction, isEven) },\n      hover: ({ index, isEven }: { index: number; isEven: boolean }) =>\n        prefersReducedMotion\n          ? { y: '0%' }\n          : {\n              y: getTargetY(direction, isEven),\n              transition: {\n                duration,\n                delay: index * delay,\n                ease: easing,\n              },\n            },\n      exit: ({ isEven }: { index: number; isEven: boolean }) =>\n        prefersReducedMotion\n          ? { y: '0%' }\n          : { y: getInitialY(direction, isEven) },\n    };\n\n    const handleHoverStart = () => {\n      if (disabled || isTouchDevice) return;\n      setIsHovered(true);\n      onAnimationStart?.();\n    };\n\n    const handleHoverEnd = () => {\n      if (disabled || isTouchDevice) return;\n      setIsHovered(false);\n      onAnimationComplete?.();\n    };\n\n    return (\n      <AnimatePresence mode='wait'>\n        <motion.div\n          className={cn(\n            'relative h-fit uppercase text-black dark:text-white leading-none',\n            'select-none transform-gpu will-change-transform',\n            !disabled && 'cursor-pointer',\n            className,\n          )}\n          variants={containerVariants}\n          initial='initial'\n          exit='exit'\n          whileHover={disabled || isTouchDevice ? undefined : 'hover'}\n          animate={\n            isTouchDevice && !disabled\n              ? isAutoAnimating\n                ? 'hover'\n                : 'initial'\n              : undefined\n          }\n          onHoverStart={handleHoverStart}\n          onHoverEnd={handleHoverEnd}\n          style={{ perspective: 1000 }}\n          role='text'\n          aria-label={text}\n          aria-live={isHovered ? 'polite' : undefined}\n        >\n          {safeBase.map((char, index) => {\n            const nextChar = safeHover[index];\n            const isSpace = char === ' ' && nextChar === ' ';\n            const isEven = index % 2 === 0;\n\n            return (\n              <span\n                key={index}\n                className='inline-block h-[1em] align-baseline overflow-hidden transform-gpu will-change-transform relative'\n                style={{ lineHeight: 1 }}\n                aria-hidden='true'\n              >\n                <motion.span\n                  className='block relative'\n                  variants={stackVariants}\n                  custom={{ index, isEven }}\n                  style={{\n                    backfaceVisibility: 'hidden',\n                    transform: 'translateZ(0)',\n                    lineHeight: 1,\n                  }}\n                >\n                  {isEven && (\n                    <span\n                      className={cn(\n                        'block h-[1em] leading-none',\n                        hoverClassName,\n                      )}\n                      style={{ lineHeight: 1 }}\n                    >\n                      {isSpace ? '\\u00A0' : nextChar}\n                    </span>\n                  )}\n                  <span\n                    className='block h-[1em] leading-none'\n                    style={{ lineHeight: 1 }}\n                  >\n                    {isSpace ? '\\u00A0' : char}\n                  </span>\n                  {!isEven && (\n                    <span\n                      className={cn(\n                        'block h-[1em] leading-none',\n                        hoverClassName,\n                      )}\n                      style={{ lineHeight: 1 }}\n                    >\n                      {isSpace ? '\\u00A0' : nextChar}\n                    </span>\n                  )}\n                </motion.span>\n              </span>\n            );\n          })}\n        </motion.div>\n      </AnimatePresence>\n    );\n  },\n);\n\nStaggerChars.displayName = 'StaggerChars';\nexport type { StaggerCharsProps };\nexport default StaggerChars;\n"
    },
    {
      "type": "registry:lib",
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}"
    }
  ]
}
