{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-reveal",
  "title": "Text Reveal",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/text-reveal/TextReveal.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { cn } from '@/lib/utils'\n\nexport interface TextRevealProps extends React.HTMLAttributes<HTMLElement> {\n  text: string\n  as?: string\n  mode?: 'words' | 'chars'\n  /** ms between segment starts */\n  stagger?: number\n  /** ms per segment transition */\n  duration?: number\n  /** ms before the first segment starts */\n  delay?: number\n  blur?: boolean\n  /** reveal only on first intersection; false re-hides when scrolled away */\n  once?: boolean\n}\n\ninterface Segment {\n  text: string\n  space: boolean\n}\n\nconst STYLE_ID = 'text-reveal-motion-styles'\nconst STYLE_CONTENT = `\n[data-slot='text-reveal'] .text-reveal-seg {\n  opacity: 0;\n  transform: translateY(0.5em);\n  transition-property: opacity, transform, filter;\n  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);\n}\n[data-slot='text-reveal'].is-revealed .text-reveal-seg {\n  opacity: 1;\n  transform: translateY(0);\n}\n[data-slot='text-reveal'] .text-reveal-blur {\n  filter: blur(8px);\n}\n[data-slot='text-reveal'].is-revealed .text-reveal-blur {\n  filter: blur(0);\n}\n@media (prefers-reduced-motion: reduce) {\n  [data-slot='text-reveal'] .text-reveal-seg {\n    opacity: 1 !important;\n    transform: none !important;\n    filter: none !important;\n    transition: none !important;\n  }\n}\n`\n\nfunction useInjectedStyles() {\n  React.useLayoutEffect(() => {\n    if (typeof document === 'undefined') return\n    let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null\n    if (!el) {\n      el = document.createElement('style')\n      el.id = STYLE_ID\n      document.head.appendChild(el)\n    }\n    if (el.textContent !== STYLE_CONTENT) el.textContent = STYLE_CONTENT\n  }, [])\n}\n\nfunction buildSegments(text: string, mode: 'words' | 'chars'): Segment[] {\n  const source = text.replace(/\\s+/g, ' ').trim()\n  if (mode === 'chars') {\n    return Array.from(source).map((ch) => ({ text: ch, space: ch === ' ' }))\n  }\n  const words = source.split(' ')\n  return words.flatMap((word, i) =>\n    i < words.length - 1\n      ? [\n          { text: word, space: false },\n          { text: '', space: true },\n        ]\n      : [{ text: word, space: false }],\n  )\n}\n\nconst TextReveal = React.forwardRef<HTMLElement, TextRevealProps>(\n  (\n    {\n      text,\n      as = 'span',\n      mode = 'words',\n      stagger = 40,\n      duration = 600,\n      delay = 0,\n      blur = true,\n      once = true,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    useInjectedStyles()\n\n    const [revealed, setRevealed] = React.useState(false)\n    const rootRef = React.useRef<HTMLElement | null>(null)\n    const observerRef = React.useRef<IntersectionObserver | null>(null)\n\n    const segments = React.useMemo(() => buildSegments(text, mode), [text, mode])\n\n    React.useEffect(() => {\n      const root = rootRef.current\n      if (!root) return\n\n      function cleanup() {\n        observerRef.current?.disconnect()\n        observerRef.current = null\n      }\n\n      const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n      if (reduce || typeof IntersectionObserver === 'undefined') {\n        setRevealed(true)\n        return cleanup\n      }\n\n      const observer = new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries) {\n            if (entry.isIntersecting) {\n              setRevealed(true)\n              if (once) cleanup()\n            } else if (!once) {\n              setRevealed(false)\n            }\n          }\n        },\n        { threshold: 0.2 },\n      )\n      observerRef.current = observer\n      observer.observe(root)\n      return cleanup\n    }, [once])\n\n    const Tag = as as React.ElementType\n\n    return (\n      <Tag\n        ref={ref ?? rootRef}\n        data-uipkge=\"\"\n        data-slot=\"text-reveal\"\n        aria-label={text}\n        aria-hidden={undefined}\n        className={cn('inline-block', revealed && 'is-revealed', className)}\n        {...props}\n      >\n        {segments.map((seg, i) =>\n          seg.space ? (\n            <span key={`space-${i}`} aria-hidden=\"true\">\n              &nbsp;\n            </span>\n          ) : (\n            <span\n              key={`${seg.text}-${i}`}\n              aria-hidden=\"true\"\n              data-slot=\"text-reveal-segment\"\n              className={cn('text-reveal-seg inline-block will-change-transform', blur && 'text-reveal-blur')}\n              style={{\n                transitionDelay: `${delay + i * stagger}ms`,\n                transitionDuration: `${duration}ms`,\n              }}\n            >\n              {seg.text}\n            </span>\n          ),\n        )}\n      </Tag>\n    )\n  },\n)\nTextReveal.displayName = 'TextReveal'\n\nexport { TextReveal }\n",
      "type": "registry:ui",
      "target": "~/components/ui/text-reveal/TextReveal.tsx"
    },
    {
      "path": "packages/registry-react/components/text-reveal/index.ts",
      "content": "export { TextReveal, type TextRevealProps } from './TextReveal'\n",
      "type": "registry:ui",
      "target": "~/components/ui/text-reveal/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Scroll-triggered staggered entrance for text — each word or character fades in, rises and un-blurs with a per-index delay. IntersectionObserver based, `once` toggle for replay-on-scroll, honors prefers-reduced-motion.",
  "categories": [
    "display",
    "motion"
  ]
}