{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cleave",
  "type": "registry:ui",
  "title": "Cleave",
  "description": "Masked line, word, or character reveal that draws text clear of hiding as it scrolls into view, each segment trailing a soft white edge glow along its baseline.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/text/cleave/cleave.tsx",
      "type": "registry:ui",
      "target": "components/crucible/cleave.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { useGSAP } from \"@gsap/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nif (typeof window !== \"undefined\") {\n  gsap.registerPlugin(ScrollTrigger, useGSAP);\n}\n\nexport interface CleaveProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The text to reveal. */\n  children: string;\n  /** Split granularity. \"line\" groups words by measured wrapped line. @default \"line\" */\n  by?: \"line\" | \"word\" | \"char\";\n  /** Element tag rendered for the wrapper. @default \"span\" */\n  as?: \"div\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"span\";\n  /** Seconds before the reveal starts. */\n  delay?: number;\n  /** Seconds between each segment's reveal, tail to head. */\n  stagger?: number;\n  /** Seconds each segment takes to slide clear of its mask. */\n  duration?: number;\n  /** Tie progress directly to scroll position instead of a one-shot trigger. @default false */\n  scrub?: boolean;\n  /** ScrollTrigger `start`, relative to the wrapper entering the scroller's viewport. */\n  start?: string;\n  /** ScrollTrigger `end` (only used when `scrub` is true). */\n  end?: string;\n  /** Re-run every time the element re-enters the viewport (non-scrub only). */\n  repeat?: boolean;\n  /**\n   * The element ScrollTrigger measures scroll progress against. Defaults to\n   * the window. Pass a ref or element to drive the reveal from an internal\n   * `overflow-y-auto` container instead.\n   */\n  scroller?: string | Element | React.RefObject<HTMLElement | null>;\n  /** Color of the edge-glow that rides each segment's leading baseline. @default soft white */\n  glowColor?: string;\n}\n\n/** Merges Unicode word segments so trailing spaces/punctuation stay attached, like anneal's char/word split. */\nfunction splitWords(text: string): string[] {\n  if (typeof Intl !== \"undefined\" && typeof Intl.Segmenter === \"function\") {\n    const seg = new Intl.Segmenter(undefined, { granularity: \"word\" });\n    const words: string[] = [];\n    for (const { segment, isWordLike } of seg.segment(text)) {\n      if (isWordLike || words.length === 0) {\n        words.push(segment);\n      } else {\n        words[words.length - 1] += segment;\n      }\n    }\n    return words;\n  }\n  return text.split(/(?<=\\s)/);\n}\n\n/** Unicode grapheme clusters (emoji/combining-mark safe) with a spread-operator fallback. */\nfunction splitGraphemes(text: string): string[] {\n  if (typeof Intl !== \"undefined\" && typeof Intl.Segmenter === \"function\") {\n    const seg = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\n    return Array.from(seg.segment(text), (s) => s.segment);\n  }\n  return [...text];\n}\n\nfunction resolveScroller(scroller: CleaveProps[\"scroller\"]): string | Element | undefined {\n  if (!scroller) return undefined;\n  if (typeof scroller === \"string\") return scroller;\n  if (scroller instanceof Element) return scroller;\n  return scroller.current ?? undefined;\n}\n\n/** True when two line-groupings are word-for-word identical, so a re-measure that\n *  produces the same wrapping can be dropped instead of re-rendering (and replaying). */\nfunction groupsEqual(a: string[][] | null, b: string[][] | null): boolean {\n  if (a === b) return true;\n  if (!a || !b || a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) {\n    const x = a[i];\n    const y = b[i];\n    if (x.length !== y.length) return false;\n    for (let j = 0; j < x.length; j++) {\n      if (x[j] !== y[j]) return false;\n    }\n  }\n  return true;\n}\n\n/**\n * Cleave — text splits into lines, words, or characters and slides clear of a\n * mask as it scrolls into view, each segment trailing a brief soft-white edge\n * glow along its leading baseline. Falls back to plain static text under\n * reduced motion.\n */\nexport function Cleave({\n  children,\n  by = \"line\",\n  // span default: Cleave usually lives inside an <h*> or <p>, where a block\n  // element would be invalid HTML (and a guaranteed hydration error).\n  as = \"span\",\n  delay = 0,\n  stagger = 0.06,\n  duration = 0.7,\n  scrub = false,\n  start = \"top 85%\",\n  end = \"bottom 60%\",\n  repeat = false,\n  scroller,\n  glowColor = \"#ffffff\",\n  className,\n  ...props\n}: CleaveProps) {\n  const reducedMotion = useReducedMotion();\n  // SSR renders plain text (identical markup server/client — no hydration\n  // mismatch, and crawlers see real text); segmentation begins after mount.\n  const [mounted, setMounted] = React.useState(false);\n  React.useEffect(() => setMounted(true), []);\n  const containerRef = React.useRef<HTMLElement | null>(null);\n  const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n  const innerRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n  const glowRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n\n  const words = React.useMemo(() => splitWords(children), [children]);\n  const graphemes = React.useMemo(() => (by === \"char\" ? splitGraphemes(children) : null), [children, by]);\n\n  const [lineGroups, setLineGroups] = React.useState<string[][] | null>(null);\n  // Last committed grouping, so a re-measure that yields identical wrapping is\n  // dropped without a re-render (which is what used to replay the reveal).\n  const groupsRef = React.useRef<string[][] | null>(null);\n  // Whether the one-shot reveal has already run to completion. A responsive\n  // re-wrap after that must land on the finished state, never re-hide-and-play.\n  const hasPlayedRef = React.useRef(false);\n\n  // \"line\" mode needs a client-side measuring pass. A permanently-mounted,\n  // visibility-hidden copy of the flat words (see the render below) wraps\n  // exactly like the visible lines, so we can read each word's offsetTop at\n  // any time — including on resize — WITHOUT tearing the visible lines back\n  // down to flat text. That teardown was the source of the flashing: it reset\n  // the timeline and replayed the reveal on every width change.\n  React.useLayoutEffect(() => {\n    // Gate on `mounted`: the flat measure words only exist once we're past the\n    // SSR plain-text render, and `mounted` flipping true is what re-runs this so\n    // it measures against those words (otherwise it fires once on the ref-less\n    // first render and never again — leaving line mode stuck as static text).\n    if (by !== \"line\" || reducedMotion || !mounted) return;\n    const container = containerRef.current;\n    if (!container) return;\n\n    const measure = () => {\n      const refs = measureRefs.current;\n      const groups: string[][] = [];\n      let lastTop: number | null = null;\n      words.forEach((word, i) => {\n        const el = refs[i];\n        if (!el) return;\n        const top = el.offsetTop;\n        if (lastTop === null || Math.abs(top - lastTop) > 1) {\n          groups.push([word]);\n          lastTop = top;\n        } else {\n          groups[groups.length - 1].push(word);\n        }\n      });\n      const next = groups.length ? groups : null;\n      // Drop no-op re-measures (e.g. a scrollbar or URL-bar showing/hiding\n      // nudges the width without changing where lines wrap): committing them\n      // would re-render and, pre-fix, replay the reveal — the \"flashing\".\n      if (groupsEqual(groupsRef.current, next)) return;\n      groupsRef.current = next;\n      setLineGroups(next);\n    };\n\n    const raf = requestAnimationFrame(measure);\n\n    // Re-measure only on real width changes (wrapping depends on width alone).\n    // Observe the parent, not the container, so our own render output can never\n    // feed back into the observer. The measurement copy is always mounted, so\n    // there is no flat<->grouped swap to re-measure against.\n    const observed = container.parentElement ?? container;\n    let lastWidth: number | null = null;\n    const ro = new ResizeObserver((entries) => {\n      const width = entries[0]?.contentRect.width ?? observed.getBoundingClientRect().width;\n      if (lastWidth !== null && Math.abs(width - lastWidth) > 1) {\n        requestAnimationFrame(measure);\n      }\n      lastWidth = width;\n    });\n    ro.observe(observed);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n    };\n  }, [by, words, reducedMotion, mounted]);\n\n  // Reset the one-shot latch when the reveal's own definition changes (text,\n  // granularity, timing, colors) so those genuinely replay — but NOT when only\n  // the line grouping changes on resize. Declared before useGSAP so it commits\n  // first: on a re-measure-only render its deps are unchanged, it no-ops, and\n  // useGSAP below sees hasPlayedRef still latched and skips the replay.\n  React.useLayoutEffect(() => {\n    hasPlayedRef.current = false;\n  }, [children, by, delay, stagger, duration, scrub, start, end, repeat, scroller, glowColor, reducedMotion]);\n\n  const segments = by === \"char\" ? (graphemes ?? []) : words;\n  // Char mode: group graphemes per word (trailing whitespace attached) so each\n  // word sits in a nowrap wrapper and lines only break at spaces, never mid-word.\n  const charGroups = React.useMemo(() => {\n    if (!graphemes) return null;\n    const groups: { start: number; items: string[] }[] = [];\n    let cur: { start: number; items: string[] } | null = null;\n    graphemes.forEach((g, i) => {\n      if (!cur) {\n        cur = { start: i, items: [] };\n        groups.push(cur);\n      }\n      cur.items.push(g);\n      if (/^\\s+$/.test(g)) cur = null;\n    });\n    return groups;\n  }, [graphemes]);\n  const isLineMode = by === \"line\";\n  const lineReady = isLineMode && !!lineGroups;\n\n  useGSAP(\n    () => {\n      if (reducedMotion) return;\n      const targets = innerRefs.current.filter((el): el is HTMLSpanElement => el !== null);\n      if (!targets.length) return;\n      const glows = glowRefs.current.filter((el): el is HTMLSpanElement => el !== null);\n\n      // A responsive re-wrap after the reveal already finished: land on the\n      // final visible state and return. Rebuilding the timeline here (re-hiding\n      // to yPercent 110 and playing again) is exactly the flashing bug.\n      if (hasPlayedRef.current && !scrub) {\n        gsap.set(targets, { yPercent: 0, opacity: 1 });\n        if (glows.length) gsap.set(glows, { opacity: 0 });\n        return;\n      }\n\n      gsap.set(targets, { yPercent: 110, opacity: 0 });\n      if (glows.length) gsap.set(glows, { opacity: 0 });\n\n      const tl = gsap.timeline({\n        scrollTrigger: {\n          trigger: containerRef.current,\n          scroller: resolveScroller(scroller),\n          start,\n          end: scrub ? end : undefined,\n          scrub,\n          toggleActions: scrub ? undefined : repeat ? \"play reverse play reverse\" : \"play none none none\",\n        },\n        delay: scrub ? 0 : delay,\n        onComplete: scrub\n          ? undefined\n          : () => {\n              // Latch once the reveal has fully played, so later re-measures\n              // (resize) skip the replay above instead of restarting it.\n              hasPlayedRef.current = true;\n            },\n      });\n\n      targets.forEach((el, i) => {\n        tl.to(el, { yPercent: 0, opacity: 1, duration, ease: \"power3.out\" }, i * stagger);\n        const glow = glowRefs.current[i];\n        if (glow) {\n          tl.fromTo(glow, { opacity: 1 }, { opacity: 0, duration: duration * 0.7, ease: \"power2.out\" }, i * stagger);\n        }\n      });\n\n      return () => {\n        tl.scrollTrigger?.kill();\n        tl.kill();\n      };\n    },\n    {\n      scope: containerRef,\n      dependencies: [\n        mounted,\n        by,\n        lineGroups,\n        segments.length,\n        reducedMotion,\n        delay,\n        stagger,\n        duration,\n        scrub,\n        start,\n        end,\n        repeat,\n        scroller,\n        glowColor,\n      ],\n      revertOnUpdate: true,\n    }\n  );\n\n  // Reset ref arrays before this render's ref callbacks repopulate them.\n  innerRefs.current = [];\n  glowRefs.current = [];\n\n  const Tag = as;\n\n  if (reducedMotion || !mounted) {\n    return (\n      <Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn(\"inline-block\", className)} {...props}>\n        <span aria-hidden>{children}</span>\n      </Tag>\n    );\n  }\n\n  if (isLineMode && !lineReady) {\n    // Measuring pass: the flat words are the visible content (a graceful\n    // fallback if measurement never resolves) and double as the measure source.\n    return (\n      <Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn(\"relative block\", className)} {...props}>\n        {words.map((word, i) => (\n          <span\n            key={i}\n            ref={(el) => {\n              measureRefs.current[i] = el;\n            }}\n            aria-hidden\n            className=\"inline\"\n          >\n            {word}\n          </span>\n        ))}\n      </Tag>\n    );\n  }\n\n  if (isLineMode) {\n    return (\n      <Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn(\"relative block\", className)} {...props}>\n        {/* Flat, visibility-hidden copy of the words, used only to measure where\n            lines wrap. As the `relative block` container's absolute child it\n            wraps at the exact same width as the visible lines, and stays mounted\n            so resize re-measures never tear down (or replay) the visible reveal. */}\n        <span aria-hidden className=\"pointer-events-none invisible absolute inset-x-0 top-0 -z-10 select-none\">\n          {words.map((word, i) => (\n            <span\n              key={i}\n              ref={(el) => {\n                measureRefs.current[i] = el;\n              }}\n              className=\"inline\"\n            >\n              {word}\n            </span>\n          ))}\n        </span>\n        {(lineGroups ?? []).map((line, i) => (\n          <span key={i} className=\"relative block overflow-hidden\">\n            <span\n              ref={(el) => {\n                innerRefs.current[i] = el;\n              }}\n              // No inline transform here: GSAP parses `translateY(110%)` as a\n              // residual pixel `y` that yPercent tweens never clear, leaving the\n              // text permanently offset. gsap.set({ yPercent: 110 }) owns the\n              // offset; the opacity pre-hide (overridden by the synchronous\n              // useGSAP set) just guards against a pre-reveal flash.\n              className=\"block will-change-transform\"\n              style={{ opacity: 0 }}\n            >\n              {line.join(\"\")}\n            </span>\n            <span\n              ref={(el) => {\n                glowRefs.current[i] = el;\n              }}\n              aria-hidden\n              className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[2px] opacity-0\"\n              style={{\n                background: `linear-gradient(90deg, transparent, ${glowColor}, transparent)`,\n                boxShadow: `0 0 10px ${glowColor}`,\n              }}\n            />\n          </span>\n        ))}\n      </Tag>\n    );\n  }\n\n  const renderSegment = (segment: string, i: number) => (\n        <span key={i} className=\"relative inline-block overflow-hidden align-bottom\">\n          <span\n            ref={(el) => {\n              innerRefs.current[i] = el;\n            }}\n            aria-hidden\n            // See line mode above: initial offset must come from gsap.set\n            // (yPercent), never an inline translateY(%), which GSAP reads as\n            // a residual pixel `y`.\n            className=\"inline-block whitespace-pre will-change-transform\"\n            style={{ opacity: 0 }}\n          >\n            {segment}\n          </span>\n          <span\n            ref={(el) => {\n              glowRefs.current[i] = el;\n            }}\n            aria-hidden\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[2px] opacity-0\"\n            style={{\n              background: `linear-gradient(90deg, transparent, ${glowColor}, transparent)`,\n              boxShadow: `0 0 10px ${glowColor}`,\n            }}\n          />\n        </span>\n  );\n\n  return (\n    <Tag ref={containerRef as React.Ref<any>} aria-label={children} className={cn(className)} {...props}>\n      {charGroups\n        ? charGroups.map((group, gi) => (\n            <span key={`g-${gi}`} className=\"inline-block whitespace-nowrap\">\n              {group.items.map((segment, k) => renderSegment(segment, group.start + k))}\n            </span>\n          ))\n        : segments.map(renderSegment)}\n    </Tag>\n  );\n}\n"
    }
  ]
}