{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-reveal","type":"registry:ui","title":"Text Reveal","description":"Text that arrives word by word or letter by letter – each piece rises out of a soft blur as it scrolls into view","dependencies":[],"files":[{"path":"motion/text-reveal/text-reveal.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties } from \"react\";\n\n/*\n * Text Reveal – a text effect, in a single self-contained file.\n * When the text scrolls into view it arrives piece by piece – word by word or letter by letter –\n * each piece fading in out of a soft blur while it rises a little, one after another.\n * Screen readers get the whole sentence at once; the moving pieces are hidden from them.\n * Font, size and color come from the parent.\n */\n\nexport type TextRevealProps = {\n  /** The text to reveal */\n  text: string;\n  /** Reveal word by word or letter by letter */\n  by?: \"word\" | \"char\";\n  /** Time between two pieces, in ms */\n  stagger?: number;\n  /** Wait before the first piece, in ms */\n  delay?: number;\n  /** Time each piece takes to arrive, in ms */\n  duration?: number;\n  /** Element to render */\n  as?: \"p\" | \"h1\" | \"h2\" | \"h3\" | \"span\";\n  /** Reveal only the first time; otherwise it plays again each time it enters the view */\n  once?: boolean;\n  className?: string;\n};\n\n/** Letters as people see them – a family emoji or a Thai syllable with its marks stays one piece */\nconst segmenter = typeof Intl !== \"undefined\" && \"Segmenter\" in Intl ? new Intl.Segmenter(undefined, { granularity: \"grapheme\" }) : null;\nconst graphemes = (s: string) => (segmenter ? Array.from(segmenter.segment(s), (x) => x.segment) : Array.from(s));\n\nexport function TextReveal({ text, by = \"word\", stagger = 40, delay = 0, duration = 700, as: Tag = \"p\", once = true, className }: TextRevealProps) {\n  const ref = useRef<HTMLElement>(null);\n  const [shown, setShown] = useState(false);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") return setShown(true);\n    const io = new IntersectionObserver(\n      (entries) => {\n        for (const e of entries) {\n          if (e.isIntersecting) {\n            setShown(true);\n            if (once) io.disconnect();\n          } else if (!once) {\n            setShown(false);\n          }\n        }\n      },\n      // From the first visible pixel – a long paragraph taller than the screen would never reach 30%\n      { threshold: 0 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [once]);\n\n  // Words and the whitespace between them; whitespace stays plain text so lines wrap normally\n  let i = 0;\n  const parts = text.split(/(\\s+)/).map((part, k) => {\n    if (part === \"\") return null;\n    if (/^\\s+$/.test(part)) return part;\n    if (by === \"word\") {\n      return (\n        <span key={k} className=\"ui-trv__p\" style={{ \"--ui-trv-i\": i++ } as CSSProperties}>\n          {part}\n        </span>\n      );\n    }\n    // Letter by letter: the letters of a word sit in one unbreakable group\n    return (\n      <span key={k} className=\"ui-trv__w\">\n        {graphemes(part).map((ch, c) => (\n          <span key={c} className=\"ui-trv__p\" style={{ \"--ui-trv-i\": i++ } as CSSProperties}>\n            {ch}\n          </span>\n        ))}\n      </span>\n    );\n  });\n\n  return (\n    <Tag\n      ref={ref as never}\n      className={[\"ui-trv\", className].filter(Boolean).join(\" \")}\n      data-shown={shown ? \"\" : undefined}\n      style={\n        {\n          \"--ui-trv-stagger\": `${stagger}ms`,\n          \"--ui-trv-delay\": `${delay}ms`,\n          \"--ui-trv-duration\": `${duration}ms`,\n        } as CSSProperties\n      }\n    >\n      {/* React 19 hoists this into <head> once, no matter how many instances render */}\n      <style href=\"ui-text-reveal\" precedence=\"default\">\n        {css}\n      </style>\n      <span className=\"ui-trv__sr\">{text}</span>\n      <span aria-hidden=\"true\">{parts}</span>\n    </Tag>\n  );\n}\n\nconst css = /* css */ `\n.ui-trv {\n  --ui-trv-blur: 8px;\n  --ui-trv-rise: 0.35em;\n  --ui-trv-ease: cubic-bezier(0.2, 0.7, 0.2, 1);\n}\n.ui-trv__sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n  border: 0;\n}\n.ui-trv__w {\n  display: inline-block;\n  max-width: 100%;\n  white-space: nowrap;\n}\n/* A word longer than the line breaks inside instead of running past it */\n.ui-trv__p, .ui-trv__w { overflow-wrap: anywhere; }\n.ui-trv__w:has(> .ui-trv__p) { white-space: normal; }\n.ui-trv__p {\n  display: inline-block;\n  opacity: 0;\n  filter: blur(var(--ui-trv-blur));\n  transform: translateY(var(--ui-trv-rise));\n  will-change: opacity, filter, transform;\n}\n.ui-trv[data-shown] .ui-trv__p {\n  animation: ui-trv-in var(--ui-trv-duration) var(--ui-trv-ease) both;\n  animation-delay: calc(var(--ui-trv-delay) + var(--ui-trv-i) * var(--ui-trv-stagger));\n}\n@keyframes ui-trv-in {\n  from {\n    opacity: 0;\n    filter: blur(var(--ui-trv-blur));\n    transform: translateY(var(--ui-trv-rise));\n  }\n  to {\n    opacity: 1;\n    filter: blur(0);\n    transform: none;\n  }\n}\n\n/* Reduced motion: the text is simply there */\n@media (prefers-reduced-motion: reduce) {\n  .ui-trv .ui-trv__p,\n  .ui-trv[data-shown] .ui-trv__p {\n    opacity: 1;\n    filter: none;\n    transform: none;\n    animation: none;\n  }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html>.\n   The text takes its color from the parent, so only the blur softens a touch on dark backgrounds */\n:where(.dark, [data-theme=\"dark\"]) .ui-trv {\n  --ui-trv-blur: 6px;\n}\n`;\n"}]}