{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/in-view-title/in-view-title.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./in-view-title.module.css\";\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 { text: string; variant?: InViewTitleVariant; as?: \"h1\" | \"h2\" | \"h3\"; lines?: string[]; className?: string; id?: string; once?: boolean }\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 = .06;\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 = .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 { y: track(settle), x: track(settle), \"--wipe\": track(settle, standard), filter: track(settle * .78), opacity: track(settle * .56, standard) };\n}\n\nexport function InViewTitle({ text, variant = \"blur\", as = \"h2\", lines, className, id, once = true }: InViewTitleProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const inView = useInView(ref, { once, amount: .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 = [styles.wrap, hydrated ? \"\" : styles.pending, className].filter(Boolean).join(\" \");\n  const words = text.split(\" \").filter(Boolean);\n  const step = Math.min(motionTokens.stagger.word * 1.5, .4 / Math.max(words.length, 1));\n\n  if (variant === \"word\") return <div ref={ref} className={wrapperClass}><Tag id={id} aria-label={text}>{words.map((word, index) => <Fragment key={`${word}-${index}`}><span className={styles.clip} aria-hidden=\"true\"><motion.span className={styles.part} initial={false} animate={visible ? { y: \"0em\", opacity: 1 } : { y: \"0.9em\", opacity: 0 }} transition={reveal(instant, visible, index * step)}>{word}</motion.span></span>{index < words.length - 1 ? \" \" : null}</Fragment>)}</Tag></div>;\n  if (variant === \"line\") {\n    const titleLines = lines?.length ? lines : [text];\n    return <div ref={ref} className={wrapperClass}><Tag id={id} aria-label={text}>{titleLines.map((line, index) => <span className={styles.lineClip} aria-hidden=\"true\" key={`${line}-${index}`}><motion.span className={styles.part} initial={false} animate={visible ? { y: \"0%\", opacity: 1 } : { y: \"112%\", opacity: 0 }} transition={reveal(instant, visible, index * motionTokens.stagger.line, .86)}>{line}</motion.span></span>)}</Tag></div>;\n  }\n  if (variant === \"blur\") return <div ref={ref} className={wrapperClass}><Tag id={id} aria-label={text}>{words.map((word, index) => <Fragment key={`${word}-${index}`}><motion.span className={`${styles.blurWord} ${styles.part}`} aria-hidden=\"true\" initial={false} animate={visible ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" } : { opacity: 0, y: \"0.2em\", filter: `blur(${motionTokens.blur.text}px)` }} transition={reveal(instant, visible, index * step)}>{word}</motion.span>{index < words.length - 1 ? \" \" : null}</Fragment>)}</Tag></div>;\n  if (variant === \"tracking\") return <div ref={ref} className={wrapperClass}><Tag id={id} aria-label={text}>{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 <Fragment key={`${word}-${index}`}><motion.span className={`${styles.trackWord} ${styles.part}`} aria-hidden=\"true\" initial={false} animate={visible ? { opacity: 1, filter: \"blur(0px)\" } : { opacity: 0, filter: `blur(${motionTokens.blur.soft}px)` }} transition={reveal(instant, visible, delay, .9)}>{letters.map((letter, letterIndex) => <motion.span key={letterIndex} className={styles.part} initial={false} animate={{ x: visible ? \"0em\" : `${(center - letterIndex) * TRACK_EM}em` }} transition={reveal(instant, visible, delay, 1)}>{letter}</motion.span>)}</motion.span>{index < words.length - 1 ? \" \" : null}</Fragment>;\n  })}</Tag></div>;\n  // A soft edged mask sweeps across the title instead of a hard clip, so no letter is ever cut mid stroke.\n  return <div ref={ref} className={wrapperClass}><motion.div className={`${styles.wipe} ${styles.part}`} initial={false} animate={visible ? { \"--wipe\": \"0%\", x: \"0em\" } : { \"--wipe\": \"100%\", x: \"-0.12em\" }} transition={reveal(instant, visible, 0, .9)}><Tag id={id}>{text}</Tag></motion.div></div>;\n}\n\nexport default InViewTitle;\n"
    },
    {
      "path": "registry/components/in-view-title/in-view-title.module.css",
      "type": "registry:component",
      "target": "@components/xzero/in-view-title/in-view-title.module.css",
      "content": ".wrap { min-width: 0; color: var(--foreground); }\n/* Clips hide the rise; padding with matching negative margins leaves room for descenders and blur without changing layout. */\n.clip { display: inline-block; overflow: hidden; vertical-align: top; padding: .08em .14em .2em; margin: -.08em -.14em -.2em; }\n.lineClip { display: block; overflow: hidden; padding: .06em .12em .18em; margin: -.06em -.12em -.18em; }\n.clip > span, .lineClip > span, .blurWord, .trackWord, .trackWord > span { display: inline-block; }\n.trackWord { white-space: nowrap; }\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. */\n.wipe { padding: .1em .12em .22em; margin: -.1em -.12em -.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/* Server rendered titles start hidden. If scripts have not taken over after 2.4s, show them anyway; the class is removed on hydration. */\n.pending .part { animation: settle .4s var(--ease-enter) 2.4s forwards; }\n@keyframes settle { to { opacity: 1; transform: none; filter: none; --wipe: 0%; mask-image: none; } }\n@media (prefers-reduced-motion: reduce) { .part { opacity: 1 !important; transform: none !important; filter: none !important; mask-image: none !important; } .pending .part { animation: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/in-view-title",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "text",
      "motion",
      "heading"
    ],
    "docs": "https://ui.xzero.co.uk/components/in-view-title",
    "markdown": "https://ui.xzero.co.uk/components/in-view-title/markdown"
  }
}