{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kinetic-headline",
  "type": "registry:ui",
  "title": "Kinetic Headline",
  "description": "Display type that rises out of masks line by line, word by word or character by character.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/kinetic-headline.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type KineticHeadlineMode = \"line\" | \"word\" | \"char\";\nexport type KineticHeadlineTrigger = \"mount\" | \"view\";\nexport type KineticHeadlineEasing = \"expo\" | \"back\" | \"smooth\";\n\nexport interface KineticHeadlineProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The headline text. Use \"\\n\" to force a line break. */\n  children: string;\n  /** Element to render. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"div\";\n  /** Reveal unit: whole lines (measured from the real layout), words or characters. */\n  mode?: KineticHeadlineMode;\n  /** Play on mount, or once the headline scrolls into view. */\n  trigger?: KineticHeadlineTrigger;\n  /** Duration of each fragment's reveal, in ms. */\n  duration?: number;\n  /** Delay between consecutive fragments, in ms. */\n  stagger?: number;\n  /** Delay before the first fragment, in ms. */\n  delay?: number;\n  /** Timing curve of the reveal. */\n  easing?: KineticHeadlineEasing;\n  /** Fade fragments in from a soft blur as they rise. */\n  blur?: boolean;\n}\n\nconst EASINGS: Record<KineticHeadlineEasing, string> = {\n  expo: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n  back: \"cubic-bezier(0.34, 1.56, 0.64, 1)\",\n  smooth: \"cubic-bezier(0.65, 0, 0.35, 1)\",\n};\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- layout --------------------------------- */\n\ninterface Word {\n  text: string;\n  /** Running index of the word across the whole headline. */\n  index: number;\n  /** Running index of the word's first character. */\n  charStart: number;\n}\n\n/** Splits text into forced lines (\\n) and words, keeping running indexes for staggering. */\nfunction splitText(text: string): Word[][] {\n  let wordIndex = 0;\n  let charIndex = 0;\n  return text.split(\"\\n\").map((line) =>\n    line\n      .split(/\\s+/)\n      .filter(Boolean)\n      .map((word) => {\n        const entry = { text: word, index: wordIndex, charStart: charIndex };\n        wordIndex += 1;\n        charIndex += Array.from(word).length;\n        return entry;\n      }),\n  );\n}\n\n// Every moving fragment shares one transition; its delay comes from the inherited --kh-i index.\nconst FRAGMENT =\n  \"inline-block [will-change:translate] [transition-property:translate,opacity,filter] \" +\n  \"[transition-duration:var(--kh-duration)] [transition-timing-function:var(--kh-ease)] \" +\n  \"[transition-delay:calc(var(--kh-delay)+var(--kh-i,0)*var(--kh-stagger))] \" +\n  \"group-data-[state=out]/kh:translate-y-[110%]\";\n\nconst FRAGMENT_BLUR = \"group-data-[state=out]/kh:opacity-0 group-data-[state=out]/kh:blur-[6px]\";\n\n/**\n * Large display type revealed line by line, word by word or character by character,\n * each fragment rising out of its own mask.\n *\n * The animation plays once. To replay it, remount the component by changing its `key`.\n */\nexport function KineticHeadline({\n  children,\n  as = \"h2\",\n  mode = \"word\",\n  trigger = \"mount\",\n  duration = 900,\n  stagger = 60,\n  delay = 0,\n  easing = \"expo\",\n  blur = false,\n  className = \"\",\n  style,\n  ...props\n}: KineticHeadlineProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const [revealed, setRevealed] = React.useState(false);\n  const Tag = as as React.ElementType;\n\n  // Start the reveal on mount or when the headline enters the viewport (once).\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (reduceMotion || !root) return;\n    let frame = 0;\n    // Two frames guarantee the hidden state has been painted, so the transition always runs.\n    const play = () => {\n      frame = requestAnimationFrame(() => {\n        frame = requestAnimationFrame(() => setRevealed(true));\n      });\n    };\n\n    if (trigger === \"mount\") {\n      play();\n      return () => cancelAnimationFrame(frame);\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) {\n          observer.disconnect();\n          play();\n        }\n      },\n      { threshold: 0.35 },\n    );\n    observer.observe(root);\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [trigger, reduceMotion]);\n\n  // In line mode, group words by their rendered line and give each mask its line index.\n  // Re-measured on resize so the stagger always follows the real wrapping.\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (mode !== \"line\" || reduceMotion || !root) return;\n    const measure = () => {\n      const masks = root.querySelectorAll<HTMLElement>(\"[data-kh-mask]\");\n      let line = -1;\n      let lastTop = -Infinity;\n      masks.forEach((mask) => {\n        if (mask.offsetTop > lastTop + 2) {\n          line += 1;\n          lastTop = mask.offsetTop;\n        }\n        mask.style.setProperty(\"--kh-i\", String(line));\n      });\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [mode, reduceMotion, children]);\n\n  if (reduceMotion) {\n    return (\n      <Tag className={`text-balance ${className}`} style={style} {...props}>\n        {children}\n      </Tag>\n    );\n  }\n\n  const lines = splitText(children);\n  const fragmentClass = blur ? `${FRAGMENT} ${FRAGMENT_BLUR}` : FRAGMENT;\n\n  return (\n    <Tag\n      ref={rootRef}\n      data-state={revealed ? \"in\" : \"out\"}\n      className={`group/kh text-balance ${className}`}\n      style={{\n        [\"--kh-duration\" as string]: `${duration}ms`,\n        [\"--kh-stagger\" as string]: `${stagger}ms`,\n        [\"--kh-delay\" as string]: `${delay}ms`,\n        [\"--kh-ease\" as string]: EASINGS[easing],\n        ...style,\n      }}\n      {...props}\n    >\n      {/* Screen readers get the sentence once, uninterrupted. */}\n      <span className=\"sr-only\">{children}</span>\n      <span aria-hidden=\"true\">\n        {lines.map((words, lineIndex) => (\n          <React.Fragment key={lineIndex}>\n            {lineIndex > 0 && <br />}\n            {words.map((word, i) => (\n              <React.Fragment key={word.index}>\n                {i > 0 && \" \"}\n                {/* Each word is a mask: fragments travel up from below its baseline. Padding keeps descenders visible. */}\n                <span\n                  data-kh-mask=\"\"\n                  className=\"-mb-[0.12em] inline-block overflow-hidden whitespace-nowrap pb-[0.12em] align-bottom\"\n                  style={mode === \"word\" ? { [\"--kh-i\" as string]: word.index } : undefined}\n                >\n                  {mode === \"char\" ? (\n                    Array.from(word.text).map((char, c) => (\n                      <span\n                        key={c}\n                        className={fragmentClass}\n                        style={{ [\"--kh-i\" as string]: word.charStart + c }}\n                      >\n                        {char}\n                      </span>\n                    ))\n                  ) : (\n                    <span className={fragmentClass}>{word.text}</span>\n                  )}\n                </span>\n              </React.Fragment>\n            ))}\n          </React.Fragment>\n        ))}\n      </span>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/kinetic-headline"
}
