{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wordmark-reveal",
  "type": "registry:ui",
  "title": "Wordmark Reveal",
  "description": "A display wordmark text reveal that assembles itself on the first scroll — every letter turns in on its own vertical axis, then a script line leans in along the baseline. A cue holds the screen until you answer it, or it plays on mount with `instant`. ScrollTrigger and SplitText.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/wordmark-reveal.tsx",
      "type": "registry:component",
      "target": "components/tweenui/wordmark-reveal.tsx",
      "content": "'use client';\n\nimport { useRef, type ComponentPropsWithoutRef } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText } from 'gsap/SplitText';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n/** Head start for the cue, so it is gone before the first letter lands. */\nconst CUE_LEAD = 0.25;\n/** Gap between the wordmark starting and the script line following it. */\nconst SCRIPT_OFFSET = 0.35;\n\ntype WordmarkRevealTag = 'h1' | 'h2' | 'h3' | 'p' | 'div';\n\nexport interface WordmarkRevealProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {\n  /** The wordmark. `\\n` starts a new line. */\n  title: string;\n  /** Script line that leans in under the wordmark, letter by letter. */\n  script?: string;\n  /** Held on screen until the reveal starts. Ignored when `instant`. */\n  cue?: string;\n  /** Play as soon as it mounts instead of waiting for a scroll. */\n  instant?: boolean;\n  /** Pixels the scroller must have travelled before it plays. */\n  scrollOffset?: number;\n  /** Seconds one letter takes to land. */\n  duration?: number;\n  /** Element rendered for the wordmark. */\n  as?: WordmarkRevealTag;\n  /** Extra classes for the script line. */\n  scriptClassName?: string;\n  /** Extra classes for the cue. */\n  cueClassName?: string;\n}\n\nexport default function WordmarkReveal({\n  title,\n  script,\n  cue = 'Scroll down to reveal',\n  instant = false,\n  scrollOffset = 80,\n  duration = 1.1,\n  as,\n  scriptClassName,\n  cueClassName,\n  className,\n  ...props\n}: WordmarkRevealProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root) return;\n\n      const select = gsap.utils.selector(root);\n      const titleEl = select('[data-wordmark]')[0] as HTMLElement | undefined;\n      if (!titleEl) return;\n\n      const scriptEl = select('[data-wordmark-script]')[0] as HTMLElement | undefined;\n      const cueEl = select('[data-wordmark-cue]')[0] as HTMLElement | undefined;\n      const cueLine = select('[data-wordmark-cue-line]')[0] as HTMLElement | undefined;\n\n      // Nothing is split or moved under reduce: the wordmark is already sitting\n      // in its finished state, and the cue is hidden in CSS.\n      if (prefersReducedMotion()) return;\n\n      const splitOf = (el: HTMLElement) =>\n        new SplitText(el, {\n          type: 'words,chars',\n          tag: 'span',\n          // Keeps a word from breaking mid-air once its letters are separate spans\n          smartWrap: true,\n        });\n\n      const splits = [splitOf(titleEl)];\n      if (scriptEl) splits.push(splitOf(scriptEl));\n      const [titleSplit, scriptSplit] = splits;\n      gsap.set(\n        splits.flatMap((split) => split.chars),\n        { display: 'inline-block' }\n      );\n\n      // A slow pulse down the cue's line, so it reads as an instruction rather\n      // than a label. Killed the moment the reveal takes over.\n      const idle = cueLine\n        ? gsap.fromTo(\n            cueLine,\n            { scaleY: 0.3, opacity: 0.35 },\n            {\n              scaleY: 1,\n              opacity: 1,\n              duration: 1.2,\n              ease: 'sine.inOut',\n              repeat: -1,\n              yoyo: true,\n              transformOrigin: 'top',\n            }\n          )\n        : null;\n\n      const tl = gsap.timeline({ paused: true, onStart: () => idle?.kill() });\n\n      if (cueEl) tl.to(cueEl, { autoAlpha: 0, y: -12, duration: 0.45, ease: 'power2.out' }, 0);\n\n      // Each letter turns on its own vertical axis, so the wordmark assembles\n      // face by face instead of fading in as one block.\n      tl.fromTo(\n        titleSplit.chars,\n        { opacity: 0, yPercent: 50, rotateY: 90, transformPerspective: 800 },\n        {\n          opacity: 1,\n          yPercent: 0,\n          rotateY: 0,\n          duration,\n          ease: 'expo.out',\n          stagger: 0.03,\n          force3D: true,\n        },\n        CUE_LEAD\n      );\n\n      // The script line leans in from the left along its own baseline, so the\n      // joins between letters close in reading order rather than all at once.\n      if (scriptSplit) {\n        tl.fromTo(\n          scriptSplit.chars,\n          { opacity: 0, x: '-0.3em', y: '0.22em', rotate: -7, transformOrigin: '50% 100%' },\n          {\n            opacity: 1,\n            x: '0em',\n            y: '0em',\n            rotate: 0,\n            duration,\n            ease: 'expo.out',\n            stagger: 0.036,\n            force3D: true,\n          },\n          CUE_LEAD + SCRIPT_OFFSET\n        );\n      }\n\n      // The server paints the wordmark before GSAP can prime it, so it ships\n      // hidden (opacity-0) and is only shown here — by which point the fromTo\n      // tweens above have already put every letter out of sight.\n      gsap.set([titleEl, scriptEl].filter(Boolean) as HTMLElement[], { opacity: 1 });\n\n      if (instant) {\n        tl.play();\n        return () => {\n          idle?.kill();\n          tl.kill();\n          splits.forEach((split) => split.revert());\n        };\n      }\n\n      // Scroll-driven: on screen is not enough. A wordmark that opens the page\n      // is on screen the moment it loads, so it also waits for the scroller to\n      // have travelled `scrollOffset` — that first nudge down the page is the\n      // cue being answered.\n      const scroller = root.closest('[data-wordmark-scroller]') as HTMLElement | null;\n      const target: HTMLElement | Window = scroller ?? window;\n      const readScroll = () => (scroller ? scroller.scrollTop : window.scrollY);\n      // A page too short to scroll can never answer the cue, so it plays on sight.\n      const canScroll = () => {\n        const box = scroller ?? document.documentElement;\n        const view = scroller ? scroller.clientHeight : window.innerHeight;\n        return box.scrollHeight > view + 1;\n      };\n\n      let inView = false;\n      let played = false;\n      const play = () => {\n        if (played || !inView) return;\n        if (canScroll() && readScroll() < scrollOffset) return;\n        played = true;\n        target.removeEventListener('scroll', play);\n        tl.play();\n      };\n      const enter = () => {\n        inView = true;\n        play();\n      };\n\n      const trigger = ScrollTrigger.create({\n        trigger: root,\n        scroller: scroller ?? undefined,\n        start: 'top 90%',\n        end: 'bottom top',\n        onEnter: enter,\n        onEnterBack: enter,\n      });\n\n      target.addEventListener('scroll', play, { passive: true });\n      // Already in view when it mounted — ScrollTrigger has no state change to\n      // report, so ask it directly.\n      if (trigger.isActive) enter();\n\n      return () => {\n        idle?.kill();\n        tl.kill();\n        trigger.kill();\n        target.removeEventListener('scroll', play);\n        splits.forEach((split) => split.revert());\n      };\n    },\n    { scope: rootRef, dependencies: [title, script, cue, instant, scrollOffset, duration] }\n  );\n\n  const Tag = as ?? 'h1';\n  const lines = title.split('\\n');\n\n  return (\n    <div\n      ref={rootRef}\n      data-wordmark-reveal\n      className={cn('flex w-full flex-col items-center', className)}\n      {...props}\n    >\n      <Tag\n        data-wordmark\n        className=\"m-0 text-center leading-[0.88] font-normal tracking-[-0.02em] uppercase opacity-0 motion-reduce:opacity-100\"\n      >\n        {lines.map((line, index) => (\n          <span key={index}>\n            {line}\n            {index < lines.length - 1 ? <br /> : null}\n          </span>\n        ))}\n      </Tag>\n\n      {script ? (\n        <p\n          data-wordmark-script\n          // Sized and spaced in em, so it keeps its proportion at any type scale.\n          // An actual script face can be pulled back up over the wordmark with\n          // scriptClassName=\"-mt-[0.4em] translate-x-[3%]\": its swash tail leaves the\n          // room an upright italic does not, and carries the word optically left.\n          className={cn(\n            'm-0 mt-[0.1em] text-center text-[0.62em] leading-none italic',\n            'opacity-0 motion-reduce:opacity-100',\n            scriptClassName\n          )}\n        >\n          {script}\n        </p>\n      ) : null}\n\n      {cue && !instant ? (\n        <div\n          data-wordmark-cue\n          aria-hidden=\"true\"\n          className={cn(\n            'mt-10 flex flex-col items-center gap-3 text-xs tracking-[0.14em] uppercase opacity-70',\n            // No reveal to wait for under reduce, so there is nothing to announce\n            'motion-reduce:hidden',\n            cueClassName\n          )}\n        >\n          <span>{cue}</span>\n          <span\n            data-wordmark-cue-line\n            className=\"h-10 w-px bg-[linear-gradient(to_bottom,currentColor,transparent)]\"\n          />\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/wordmark-reveal\nGSAP plugins (ScrollTrigger, SplitText) are registered inside the component, so there is nothing to set up."
}