{"$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 as it scrolls into view: blurring in, rising from a line, fading or wiped in.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json"],"files":[{"path":"registry/dashral/text-reveal.tsx","type":"registry:file","target":"components/dashral/text-reveal.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"./lib/utils\"\n\ntype TextRevealVariant = \"blur\" | \"rise\" | \"fade\" | \"wipe\"\n\ntype TextRevealProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  /** Plain text: it's split into words, and each arrives a moment after the one before. */\n  children: string\n  /** The element it renders: a heading, a paragraph or inline text. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\" | \"p\" | \"span\" | \"div\"\n  /**\n   * How the words arrive: un-blurring as they rise (blur), sliding up from behind a line (rise), fading in (fade), or\n   * the whole text wiped in from the start of the line (wipe).\n   */\n  variant?: TextRevealVariant\n  /** When it plays: the first time it scrolls into view, or as soon as it's on the page. */\n  trigger?: \"view\" | \"mount\"\n  /** Milliseconds before the first word, e.g. to follow a heading that reveals first. */\n  delay?: number\n  /** Milliseconds between one word and the next. */\n  stagger?: number\n  classNames?: { word?: string }\n}\n\n// The hidden state, only while scripts run and motion is welcome: without either, the words are simply there. It's\n// keyed on the root's data-reveal, so the server's HTML is already hidden and nothing flashes before it plays. Written\n// out in full so Tailwind finds every class.\nconst hiddenWord: Record<Exclude<TextRevealVariant, \"wipe\">, string> = {\n  blur: \"[@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:opacity-0 [@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:blur-[10px] [@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:translate-y-[0.35em]\",\n  rise: \"[@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:translate-y-[110%]\",\n  fade: \"[@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:opacity-0\",\n}\nconst hiddenWipe = \"[@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:[clip-path:inset(0_100%_0_0)] rtl:[@media(scripting:enabled)]:motion-safe:group-data-[reveal=pending]/reveal:[clip-path:inset(0_0_0_100%)]\"\nconst wordMotion: Record<Exclude<TextRevealVariant, \"wipe\">, string> = {\n  blur: \"transition-[opacity,filter,translate] duration-(--duration-considered) ease-enter\",\n  rise: \"transition-[translate] duration-[calc(var(--duration-considered)*1.3)] ease-enter\",\n  fade: \"transition-opacity duration-(--duration-considered) ease-enter\",\n}\n\n/**\n * Text that arrives word by word, in one tag: as it scrolls into view or on load, blurring in, rising from a line,\n * fading in or wiped in. Screen readers and search engines get the whole text; with reduced motion, or before scripts\n * run, it's simply there.\n */\nfunction TextReveal({\n  children,\n  as: Tag = \"p\",\n  variant = \"blur\",\n  trigger = \"view\",\n  delay = 0,\n  stagger = 55,\n  className,\n  classNames,\n  ...props\n}: TextRevealProps) {\n  const ref = React.useRef<HTMLElement>(null)\n  const [shown, setShown] = React.useState(false)\n\n  React.useEffect(() => {\n    const node = ref.current\n    if (!node) return\n    // Settle the hidden state before anything changes it. On a reload with the scripts cached, hydration can land\n    // before the first paint; if the browser never computed the hidden styles, the switch to shown has nothing to\n    // transition from and the words just appear. Reading layout forces that style pass.\n    void node.getBoundingClientRect()\n    if (trigger === \"mount\") {\n      const frame = requestAnimationFrame(() => setShown(true))\n      return () => cancelAnimationFrame(frame)\n    }\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return\n        setShown(true)\n        observer.disconnect()\n      },\n      { rootMargin: \"0px 0px -12% 0px\" }\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [trigger])\n\n  const words = children.split(/\\s+/).filter(Boolean)\n  const root = cn(\"group/reveal\", className)\n\n  if (variant === \"wipe\") {\n    return (\n      <Tag ref={ref as React.Ref<never>} data-slot=\"text-reveal\" data-reveal={shown ? \"shown\" : \"pending\"} className={root} {...props}>\n        <span\n          className={cn(\n            \"inline-block [clip-path:inset(0_0_0_0)] transition-[clip-path] duration-[calc(var(--duration-considered)*2)] ease-enter motion-reduce:transition-none\",\n            hiddenWipe\n          )}\n          style={{ transitionDelay: `${delay}ms` }}\n        >\n          {children}\n        </span>\n      </Tag>\n    )\n  }\n\n  return (\n    <Tag ref={ref as React.Ref<never>} data-slot=\"text-reveal\" data-reveal={shown ? \"shown\" : \"pending\"} className={root} {...props}>\n      {/* The spaces sit between the words: inside an inline-block they would collapse. */}\n      {words.map((word, i) => {\n        const timing = { transitionDelay: `${delay + i * stagger}ms` }\n        const motion = cn(\"inline-block motion-reduce:transition-none\", wordMotion[variant], hiddenWord[variant], classNames?.word)\n        return (\n          <React.Fragment key={i}>\n            {variant === \"rise\" ? (\n              // A window as tall as the line (with room for descenders) that the word slides up into.\n              <span className=\"-mb-[0.12em] inline-block overflow-hidden pb-[0.12em] align-bottom\">\n                <span className={motion} style={timing}>\n                  {word}\n                </span>\n              </span>\n            ) : (\n              <span className={motion} style={timing}>\n                {word}\n              </span>\n            )}\n            {i < words.length - 1 && \" \"}\n          </React.Fragment>\n        )\n      })}\n    </Tag>\n  )\n}\n\nexport { TextReveal, type TextRevealProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}