{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kinetic-text-reveal",
  "type": "registry:ui",
  "title": "Kinetic Text Reveal",
  "dependencies": [
    "framer-motion"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Directional text reveal with soft blur and configurable word, character, or line stagger timing.",
  "files": [
    {
      "path": "components/ui/kinetic-text-reveal.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  useReducedMotion,\n  type Transition,\n  type Variants,\n} from \"framer-motion\";\nimport {\n  forwardRef,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useState,\n  type HTMLAttributes,\n} from \"react\";\n\ntype SplitMode = \"words\" | \"characters\" | \"lines\";\ntype RevealDirection = \"up\" | \"down\" | \"left\" | \"right\";\ntype StaggerOrigin = \"start\" | \"end\" | \"center\" | \"edges\" | \"random\" | number;\n\nexport interface KineticTextRevealRef {\n  /** Starts or replays the reveal animation. */\n  play: () => void;\n  /** Moves the text back to its hidden state. */\n  reset: () => void;\n}\n\ninterface KineticTextRevealProps extends Omit<\n  HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  /** Text content to reveal. */\n  text: string;\n  /** Additional CSS classes for the outer element. */\n  className?: string;\n  /** CSS classes applied to each animated text segment. */\n  segmentClassName?: string;\n  /** CSS classes applied to each clipping wrapper. */\n  maskClassName?: string;\n  /** How the text is segmented before animation. */\n  splitBy?: SplitMode;\n  /** Direction each segment travels from. */\n  direction?: RevealDirection;\n  /** Distance each segment travels in pixels. */\n  distance?: number;\n  /** Delay between animated segments in seconds. */\n  stagger?: number;\n  /** Where the stagger wave begins. */\n  staggerFrom?: StaggerOrigin;\n  /** Animation transition for each segment. */\n  transition?: Transition;\n  /** Adds blur while segments are hidden. */\n  blur?: boolean;\n  /** Starts automatically after mount. */\n  autoPlay?: boolean;\n  /** Optional delay before the automatic reveal begins, in seconds. */\n  delay?: number;\n  /** Called when the reveal begins. */\n  onRevealStart?: () => void;\n  /** Called after the last segment completes. */\n  onRevealComplete?: () => void;\n}\n\ninterface Segment {\n  value: string;\n  animated: boolean;\n  index: number;\n}\n\nfunction splitIntoGraphemes(value: string): string[] {\n  if (typeof Intl !== \"undefined\" && \"Segmenter\" in Intl) {\n    const segmenter = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\n    return Array.from(segmenter.segment(value), ({ segment }) => segment);\n  }\n\n  return Array.from(value);\n}\n\nfunction getSegments(text: string, splitBy: SplitMode): Segment[] {\n  let animatedIndex = 0;\n\n  if (splitBy === \"lines\") {\n    return text.split(\"\\n\").map((line) => {\n      const animated = line.length > 0;\n      return {\n        value: line,\n        animated,\n        index: animated ? animatedIndex++ : -1,\n      };\n    });\n  }\n\n  if (splitBy === \"characters\") {\n    return splitIntoGraphemes(text).map((character) => {\n      const animated = !/\\s/.test(character);\n      return {\n        value: character,\n        animated,\n        index: animated ? animatedIndex++ : -1,\n      };\n    });\n  }\n\n  return text.split(/(\\s+)/).map((part) => {\n    const animated = !/^\\s+$/.test(part) && part.length > 0;\n    return {\n      value: part,\n      animated,\n      index: animated ? animatedIndex++ : -1,\n    };\n  });\n}\n\nfunction getDelay(\n  index: number,\n  total: number,\n  stagger: number,\n  staggerFrom: StaggerOrigin,\n) {\n  if (typeof staggerFrom === \"number\") {\n    return Math.abs(staggerFrom - index) * stagger;\n  }\n\n  if (staggerFrom === \"end\") {\n    return (total - 1 - index) * stagger;\n  }\n\n  if (staggerFrom === \"center\") {\n    return Math.abs((total - 1) / 2 - index) * stagger;\n  }\n\n  if (staggerFrom === \"edges\") {\n    return Math.min(index, total - 1 - index) * stagger;\n  }\n\n  if (staggerFrom === \"random\") {\n    const seeded = Math.abs(Math.sin(index * 12.9898) * 43758.5453) % 1;\n    return Math.floor(seeded * total) * stagger;\n  }\n\n  return index * stagger;\n}\n\nfunction getOffset(direction: RevealDirection, distance: number) {\n  if (direction === \"down\") return { x: 0, y: -distance };\n  if (direction === \"left\") return { x: distance, y: 0 };\n  if (direction === \"right\") return { x: -distance, y: 0 };\n  return { x: 0, y: distance };\n}\n\nexport const KineticTextReveal = forwardRef<\n  KineticTextRevealRef,\n  KineticTextRevealProps\n>(\n  (\n    {\n      text,\n      className,\n      segmentClassName,\n      maskClassName,\n      splitBy = \"words\",\n      direction = \"up\",\n      distance = 20,\n      stagger = 0.075,\n      staggerFrom = \"start\",\n      transition = { duration: 0.72, ease: [0.22, 1, 0.36, 1] },\n      blur = true,\n      autoPlay = true,\n      delay = 0,\n      onRevealStart,\n      onRevealComplete,\n      ...props\n    },\n    ref,\n  ) => {\n    const shouldReduceMotion = useReducedMotion();\n    const [run, setRun] = useState(0);\n    const [visible, setVisible] = useState(false);\n\n    const segments = useMemo(() => getSegments(text, splitBy), [text, splitBy]);\n    const animatedTotal = segments.filter((segment) => segment.animated).length;\n\n    useImperativeHandle(ref, () => ({\n      play: () => {\n        setVisible(false);\n        requestAnimationFrame(() => {\n          setRun((current) => current + 1);\n          setVisible(true);\n          onRevealStart?.();\n        });\n      },\n      reset: () => setVisible(false),\n    }));\n\n    useEffect(() => {\n      if (!autoPlay) return;\n\n      const timeout = window.setTimeout(() => {\n        setRun((current) => current + 1);\n        setVisible(true);\n        onRevealStart?.();\n      }, delay * 1000);\n\n      return () => window.clearTimeout(timeout);\n    }, [autoPlay, delay, text, onRevealStart]);\n\n    const offset = getOffset(direction, distance);\n\n    const variants: Variants = {\n      hidden: shouldReduceMotion\n        ? { opacity: 0 }\n        : {\n            opacity: 0,\n            x: offset.x,\n            y: offset.y,\n            filter: blur ? \"blur(6px)\" : \"blur(0px)\",\n          },\n      visible: (index: number) => ({\n        opacity: 1,\n        x: 0,\n        y: 0,\n        filter: \"blur(0px)\",\n        transition: shouldReduceMotion\n          ? { duration: 0.01 }\n          : {\n              ...transition,\n              delay: getDelay(index, animatedTotal, stagger, staggerFrom),\n            },\n      }),\n    };\n\n    return (\n      <span\n        className={cn(\n          \"inline-flex flex-wrap whitespace-pre-wrap align-baseline\",\n          splitBy === \"lines\" && \"flex-col items-start\",\n          className,\n        )}\n        aria-label={text}\n        {...props}\n      >\n        <span className=\"sr-only\">{text}</span>\n        {segments.map((segment, index) => {\n          if (!segment.animated) {\n            return (\n              <span key={`${run}-${index}`} aria-hidden=\"true\">\n                {segment.value}\n              </span>\n            );\n          }\n\n          return (\n            <span\n              key={`${run}-${index}`}\n              className={cn(\n                \"inline-block overflow-hidden align-baseline pb-1\",\n                maskClassName,\n              )}\n              aria-hidden=\"true\"\n            >\n              <motion.span\n                custom={segment.index}\n                variants={variants}\n                initial=\"hidden\"\n                animate={visible ? \"visible\" : \"hidden\"}\n                className={cn(\n                  \"inline-block will-change-transform\",\n                  segmentClassName,\n                )}\n                onAnimationComplete={\n                  segment.index === animatedTotal - 1\n                    ? onRevealComplete\n                    : undefined\n                }\n              >\n                {segment.value}\n              </motion.span>\n            </span>\n          );\n        })}\n      </span>\n    );\n  },\n);\n\nKineticTextReveal.displayName = \"KineticTextReveal\";\n",
      "type": "registry:ui"
    }
  ]
}
