{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-reveal",
  "type": "registry:ui",
  "title": "Text Reveal",
  "description": "Words or wrapped lines lift out of a blur once, text left intact.",
  "categories": [
    "content"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/text-reveal.tsx",
      "content": "\"use client\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, stagger as staggers } from \"@/lib/motion\";\n\ntype Tag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\";\n\nexport type TextRevealProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  /** The text. Plain text only, so it can be split into words. */\n  children: string;\n  /** The element to render. A heading keeps its heading semantics. */\n  as?: Tag;\n  /** Reveal word by word (headings), or line by line as the text actually wraps (paragraphs). */\n  by?: \"word\" | \"line\";\n  /** Reveal when it scrolls into view, or as soon as it mounts (above the fold). */\n  trigger?: \"view\" | \"mount\";\n  /** Seconds before the first unit. */\n  delay?: number;\n  /** Seconds between units. Long text compresses it so the last unit never trails behind `maxSpread`. */\n  stagger?: number;\n  /** Seconds from the first unit starting to the last one starting, at most. */\n  maxSpread?: number;\n  /** Seconds for each unit's entrance. */\n  duration?: number;\n  /** How far each unit lifts, as a CSS length. Em keeps it proportional to the type size. */\n  distance?: string;\n  /** The blur it clears from, as a CSS length. */\n  blur?: string;\n  /** The scroll container, when it isn't the page. */\n  root?: React.RefObject<Element | null>;\n  /** Called once, when the last unit has settled. */\n  onRevealed?: () => void;\n  ref?: React.Ref<HTMLElement>;\n};\n\n/**\n * Words (or lines) lift out of a blur in reading order, once, on first view.\n * The whole string stays in the element for screen readers, find-in-page and\n * copy; the animated split is decoration on top. With reduced motion, or\n * without JavaScript, the text is simply there.\n */\nexport function TextReveal({\n  children,\n  as: Tag = \"span\",\n  by = \"word\",\n  trigger = \"view\",\n  delay = 0,\n  stagger,\n  maxSpread = 0.6,\n  duration,\n  distance = \"0.3em\",\n  blur = \"0.2em\",\n  root,\n  onRevealed,\n  className,\n  ref,\n  ...rest\n}: TextRevealProps) {\n  const reduce = useReducedMotion();\n  const self = useRef<HTMLElement>(null);\n  const opts = useRef({ by, delay, stagger, maxSpread, duration, distance, blur, onRevealed });\n  useEffect(() => {\n    opts.current = { by, delay, stagger, maxSpread, duration, distance, blur, onRevealed };\n  });\n\n  useEffect(() => {\n    const el = self.current;\n    if (!el || el.hasAttribute(\"data-revealed\")) return;\n    const show = () => {\n      el.setAttribute(\"data-revealed\", \"\");\n      opts.current.onRevealed?.();\n    };\n    if (reduce) return show();\n\n    const running: Animation[] = [];\n    const play = () => {\n      const o = opts.current;\n      const units = Array.from(el.querySelectorAll<HTMLElement>(\"[data-unit]\"));\n      // Lines are whatever the browser wrapped at this width, read at the moment\n      // of playing, so a resize before the reveal never groups words wrongly.\n      const groups: HTMLElement[][] = [];\n      if (o.by === \"line\") {\n        let top = 0;\n        for (const u of units) {\n          if (!groups.length || Math.abs(u.offsetTop - top) > 2) groups.push([]);\n          top = u.offsetTop;\n          groups[groups.length - 1].push(u);\n        }\n      } else units.forEach((u) => groups.push([u]));\n\n      const each = o.stagger ?? (o.by === \"line\" ? staggers.lines : staggers.words);\n      const step = groups.length > 1 ? Math.min(each, o.maxSpread / (groups.length - 1)) : 0;\n      const length = (o.duration ?? (o.by === \"line\" ? 0.8 : 0.64)) * 1000;\n      groups.forEach((group, i) =>\n        group.forEach((u) =>\n          running.push(\n            // Web Animations with fill \"backwards\": held hidden through the delay,\n            // and nothing left on the element afterwards.\n            u.animate(\n              [\n                { opacity: 0, transform: `translateY(${o.distance})`, filter: `blur(${o.blur})` },\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n              ],\n              { duration: length, delay: (o.delay + i * step) * 1000, easing: `cubic-bezier(${ease.out.join(\",\")})`, fill: \"backwards\" },\n            ),\n          ),\n        ),\n      );\n      el.setAttribute(\"data-revealed\", \"\");\n      running.at(-1)?.finished.then(() => opts.current.onRevealed?.(), () => {});\n    };\n\n    if (trigger === \"mount\") {\n      play();\n      return () => running.forEach((a) => a.cancel());\n    }\n\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        // Scrolled past before it was seen (an anchor jump, a restored scroll): no show.\n        const above = entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0);\n        if (!entry.isIntersecting && !above) return;\n        io.disconnect();\n        if (above) show();\n        else play();\n      },\n      { root: root?.current ?? null, rootMargin: \"0px 0px -10% 0px\" },\n    );\n    io.observe(el);\n    return () => {\n      io.disconnect();\n      running.forEach((a) => a.cancel());\n    };\n  }, [reduce, trigger, root]);\n\n  // Whitespace stays as real text between the word boxes, so the browser wraps\n  // (and balances) the line exactly as it would the plain string.\n  const parts = children.split(/(\\s+)/).filter(Boolean);\n\n  return (\n    <>\n      <Tag\n        ref={(node: HTMLElement | null) => {\n          self.current = node;\n          if (typeof ref === \"function\") return ref(node);\n          if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;\n        }}\n        data-text-reveal=\"\"\n        data-by={by}\n        className={cn(\n          // Hidden until revealed, only when motion is welcome; reduced motion never hides anything.\n          \"motion-safe:[&:not([data-revealed])_[data-unit]]:opacity-0 print:[&_[data-unit]]:opacity-100!\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"sr-only select-none\">{children}</span>\n        <span aria-hidden>\n          {parts.map((part, i) =>\n            /^\\s+$/.test(part) ? (\n              part\n            ) : (\n              <span key={i} data-unit=\"\" className=\"inline-block\">\n                {part}\n              </span>\n            ),\n          )}\n        </span>\n      </Tag>\n      {/* Without JavaScript nothing would ever reveal, so nothing hides. */}\n      <noscript>\n        <style>{\"[data-text-reveal] [data-unit]{opacity:1!important}\"}</style>\n      </noscript>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/text-reveal"
}
