{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "in-view-title",
  "type": "registry:ui",
  "title": "In-view title",
  "description": "Bring a section title in as it scrolls into view.",
  "categories": [
    "text"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/in-view-title/in-view-title.tsx",
      "content": "\"use client\";\nimport { Fragment, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { motion, useInView, useReducedMotion, type Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type InViewTitleVariant = \"word\" | \"line\" | \"blur\" | \"tracking\" | \"wipe\";\n/**\n * A section title that reveals itself once it scrolls into view. `blur` sharpens word by word and suits most headings; `word` and `line` rise out of a clip for editorial pages;\n * `tracking` opens tight letter spacing; `wipe` uncovers the title from left to right through a soft edge. Pass `lines` to set the breaks for `line`, and `once={false}` to replay on every entry.\n * Titles that are already above the viewport show immediately, and server rendered titles appear on their own if scripts are slow.\n */\nexport interface InViewTitleProps {\n  text: string;\n  variant?: InViewTitleVariant;\n  as?: \"h1\" | \"h2\" | \"h3\";\n  lines?: string[];\n  className?: string;\n  id?: string;\n  once?: boolean;\n}\nconst subscribeHydration = () => () => {};\nconst clientSnapshot = () => true;\nconst serverSnapshot = () => false;\n/** Matches the delay of the stylesheet fallback: when scripts arrive later than this, the fallback has already shown the title, so it stays put instead of animating again. */\nconst FALLBACK_MS = 2400;\nconst lateBoot = typeof window !== \"undefined\" && performance.now() > FALLBACK_MS;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n/** Horizontal distance each letter travels per step from its word's center in the tracking variant. */\nconst TRACK_EM = 0.06;\n/** Every moving part: reduced motion pins it at rest, whatever motion has written inline. */\nconst PART =\n  \"motion-reduce:opacity-100! motion-reduce:transform-none! motion-reduce:filter-none! motion-reduce:[mask-image:none]!\";\n/** Server rendered titles start hidden. If scripts have not taken over after 2.4s, show them anyway; the class is dropped on hydration. */\nconst PENDING = \"animate-in-view-title-settle motion-reduce:animate-none\";\n/** Clips hide the rise; padding with matching negative margins leaves room for descenders and blur without changing layout. */\nconst CLIP =\n  \"inline-block overflow-hidden align-top -mx-[.14em] -mt-[.08em] -mb-[.2em] px-[.14em] pt-[.08em] pb-[.2em]\";\nconst LINE_CLIP = \"block overflow-hidden -mx-[.12em] -mt-[.06em] -mb-[.18em] px-[.12em] pt-[.06em] pb-[.18em]\";\n/** The wipe mask is 2.3 times the title's width with a feathered middle, so the soft edge sits fully outside the title at both ends of the sweep.\n *  Padding with matching negative margins keeps descenders and overhangs inside the masked box without changing layout. */\nconst WIPE =\n  \"-mx-[.12em] -mt-[.1em] -mb-[.22em] px-[.12em] pt-[.1em] pb-[.22em] [mask-image:linear-gradient(90deg,#000_44%,transparent_56%)] [mask-size:230%_100%] [mask-repeat:no-repeat] [mask-position:var(--wipe,0%)_0]\";\n\n/** Transform settles longest, blur clears sooner, and opacity leads, so text is readable before it has fully arrived. Hiding is a quick fade with no stagger. */\nfunction reveal(instant: boolean, visible: boolean, delay: number, settle = 0.8): Transition {\n  if (instant) return { duration: 0 };\n  if (!visible) return { duration: motionTokens.duration.exit, ease: standard };\n  const track = (duration: number, ease = enter) => ({ duration, delay, ease });\n  return {\n    y: track(settle),\n    x: track(settle),\n    \"--wipe\": track(settle, standard),\n    filter: track(settle * 0.78),\n    opacity: track(settle * 0.56, standard),\n  };\n}\n\nexport function InViewTitle({\n  text,\n  variant = \"blur\",\n  as = \"h2\",\n  lines,\n  className,\n  id,\n  once = true,\n}: InViewTitleProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const inView = useInView(ref, { once, amount: 0.45 });\n  const [passed, setPassed] = useState(false);\n  useEffect(() => {\n    if (inView || passed) return;\n    const revealPassedTitle = () => {\n      if (ref.current && ref.current.getBoundingClientRect().top < 0) setPassed(true);\n    };\n    revealPassedTitle();\n    window.addEventListener(\"scroll\", revealPassedTitle, { passive: true });\n    return () => window.removeEventListener(\"scroll\", revealPassedTitle);\n  }, [inView, passed]);\n  const prefersReduced = useReducedMotion();\n  const hydrated = useSyncExternalStore(subscribeHydration, clientSnapshot, serverSnapshot);\n  const [serverRendered] = useState(!hydrated);\n  const reduced = hydrated && prefersReduced;\n  const late = hydrated && serverRendered && lateBoot;\n  const visible = reduced || late || inView || passed;\n  const instant = Boolean(reduced || late);\n  const Tag = as;\n  const wrapperClass = cn(\"min-w-0 text-ink\", className);\n  const part = (...extra: string[]) => cn(...extra, PART, !hydrated && PENDING);\n  const words = text.split(\" \").filter(Boolean);\n  const step = Math.min(motionTokens.stagger.word * 1.5, 0.4 / Math.max(words.length, 1));\n\n  if (variant === \"word\")\n    return (\n      <div ref={ref} className={wrapperClass}>\n        <Tag id={id} aria-label={text}>\n          {words.map((word, index) => (\n            <Fragment key={`${word}-${index}`}>\n              <span className={CLIP} aria-hidden=\"true\">\n                <motion.span\n                  className={part(\"inline-block\")}\n                  initial={false}\n                  animate={visible ? { y: \"0em\", opacity: 1 } : { y: \"0.9em\", opacity: 0 }}\n                  transition={reveal(instant, visible, index * step)}\n                >\n                  {word}\n                </motion.span>\n              </span>\n              {index < words.length - 1 ? \" \" : null}\n            </Fragment>\n          ))}\n        </Tag>\n      </div>\n    );\n  if (variant === \"line\") {\n    const titleLines = lines?.length ? lines : [text];\n    return (\n      <div ref={ref} className={wrapperClass}>\n        <Tag id={id} aria-label={text}>\n          {titleLines.map((line, index) => (\n            <span className={LINE_CLIP} aria-hidden=\"true\" key={`${line}-${index}`}>\n              <motion.span\n                className={part(\"inline-block\")}\n                initial={false}\n                animate={visible ? { y: \"0%\", opacity: 1 } : { y: \"112%\", opacity: 0 }}\n                transition={reveal(instant, visible, index * motionTokens.stagger.line, 0.86)}\n              >\n                {line}\n              </motion.span>\n            </span>\n          ))}\n        </Tag>\n      </div>\n    );\n  }\n  if (variant === \"blur\")\n    return (\n      <div ref={ref} className={wrapperClass}>\n        <Tag id={id} aria-label={text}>\n          {words.map((word, index) => (\n            <Fragment key={`${word}-${index}`}>\n              <motion.span\n                className={part(\"inline-block\")}\n                aria-hidden=\"true\"\n                initial={false}\n                animate={\n                  visible\n                    ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" }\n                    : { opacity: 0, y: \"0.2em\", filter: `blur(${motionTokens.blur.text}px)` }\n                }\n                transition={reveal(instant, visible, index * step)}\n              >\n                {word}\n              </motion.span>\n              {index < words.length - 1 ? \" \" : null}\n            </Fragment>\n          ))}\n        </Tag>\n      </div>\n    );\n  if (variant === \"tracking\")\n    return (\n      <div ref={ref} className={wrapperClass}>\n        <Tag id={id} aria-label={text}>\n          {words.map((word, index) => {\n            const letters = Array.from(word);\n            const center = (letters.length - 1) / 2;\n            const delay = index * motionTokens.stagger.char * 2;\n            return (\n              <Fragment key={`${word}-${index}`}>\n                <motion.span\n                  className={part(\"inline-block whitespace-nowrap\")}\n                  aria-hidden=\"true\"\n                  initial={false}\n                  animate={\n                    visible\n                      ? { opacity: 1, filter: \"blur(0px)\" }\n                      : { opacity: 0, filter: `blur(${motionTokens.blur.soft}px)` }\n                  }\n                  transition={reveal(instant, visible, delay, 0.9)}\n                >\n                  {letters.map((letter, letterIndex) => (\n                    <motion.span\n                      key={letterIndex}\n                      className={part(\"inline-block\")}\n                      initial={false}\n                      animate={{ x: visible ? \"0em\" : `${(center - letterIndex) * TRACK_EM}em` }}\n                      transition={reveal(instant, visible, delay, 1)}\n                    >\n                      {letter}\n                    </motion.span>\n                  ))}\n                </motion.span>\n                {index < words.length - 1 ? \" \" : null}\n              </Fragment>\n            );\n          })}\n        </Tag>\n      </div>\n    );\n  // A soft edged mask sweeps across the title instead of a hard clip, so no letter is ever cut mid stroke.\n  return (\n    <div ref={ref} className={wrapperClass}>\n      <motion.div\n        className={part(WIPE)}\n        initial={false}\n        animate={visible ? { \"--wipe\": \"0%\", x: \"0em\" } : { \"--wipe\": \"100%\", x: \"-0.12em\" }}\n        transition={reveal(instant, visible, 0, 0.9)}\n      >\n        <Tag id={id}>{text}</Tag>\n      </motion.div>\n    </div>\n  );\n}\n\nexport default InViewTitle;\n",
      "type": "registry:component",
      "target": "@components/iris/in-view-title/in-view-title.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/in-view-title",
  "meta": {
    "kind": "component",
    "group": "Text & motion",
    "export": "InViewTitle",
    "tags": [
      "text",
      "motion",
      "heading"
    ],
    "docs": "https://irisui.dev/components/in-view-title"
  }
}
