{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-text",
  "title": "Split Text",
  "description": "Characters or words stagger into view when scrolled to. Powered by Motion.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/mewo/ui/split-text.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useInView, useReducedMotion, type Transition, type Variants } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface SplitTextProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  children: string\n  by?: \"chars\" | \"words\"\n  /** Seconds between each unit. */\n  stagger?: number\n  /** Seconds before the first unit. */\n  delay?: number\n  /** Play once, or replay every time it scrolls into view. */\n  once?: boolean\n  as?: \"span\" | \"h1\" | \"h2\" | \"h3\" | \"p\"\n}\n\n// Intl.Segmenter's declarations live in lib.es2022.intl, and this file is compiled by whatever\n// `lib` the project it is installed into uses. Declaring the shape here keeps it compiling under\n// ES2017 and up; the runtime check is what decides whether the API is really there.\ntype GraphemeSegmenter = { segment(input: string): Iterable<{ segment: string }> }\ntype GraphemeSegmenterConstructor = new (\n  locales: undefined,\n  options: { granularity: \"grapheme\" }\n) => GraphemeSegmenter\n\nexport function splitUnits(text: string, by: \"chars\" | \"words\"): string[] {\n  if (by === \"words\") return text.split(/(\\s+)/).filter((unit) => unit.length > 0)\n  // Code points tear emoji, flags and combining marks apart — a unit must be a whole grapheme cluster.\n  const Segmenter =\n    typeof Intl === \"undefined\"\n      ? undefined\n      : (Intl as unknown as { Segmenter?: GraphemeSegmenterConstructor }).Segmenter\n  if (Segmenter) {\n    const segmenter = new Segmenter(undefined, { granularity: \"grapheme\" })\n    return Array.from(segmenter.segment(text), (segment) => segment.segment)\n  }\n  return Array.from(text)\n}\n\n// A quartic ease-out: the curve GSAP calls power3.out.\nconst EASE = [0.165, 0.84, 0.44, 1] as const\n\nconst UNIT: Variants = {\n  hidden: { y: \"110%\", opacity: 0 },\n  shown: { y: \"0%\", opacity: 1 },\n}\n\n/*\n * One viewport observer watches the whole string and every unit follows it, so the cost is one\n * IntersectionObserver however long the text. Motion's frame loop runs only while units are moving and\n * sleeps once the entrance has played: there is no ticker left running for the life of the page.\n */\nexport function SplitText({\n  children,\n  by = \"chars\",\n  stagger = 0.03,\n  delay = 0,\n  once = true,\n  as: Tag = \"span\",\n  className,\n  ...props\n}: SplitTextProps) {\n  const ref = React.useRef<HTMLElement>(null)\n  const reduceMotion = useReducedMotion()\n  // \"Top of the text 85% of the way down the viewport\" is where the entrance starts.\n  const inView = useInView(ref, { once, margin: \"0px 0px -15% 0px\" })\n\n  // Each unit's place in the stagger, counting only the units that animate.\n  const units = React.useMemo(() => {\n    let order = 0\n    return splitUnits(children, by).map((text) => ({ text, order: /^\\s+$/.test(text) ? -1 : order++ }))\n  }, [children, by])\n\n  const transitionFor = (order: number): Transition => {\n    if (reduceMotion) return { duration: 0 }\n    // Leaving the viewport (only when `once` is off) sends every unit back at once.\n    if (!inView) return { duration: 0.3, ease: EASE }\n    return { duration: 0.6, ease: EASE, delay: delay + stagger * order }\n  }\n\n  return React.createElement(\n    Tag,\n    {\n      ref,\n      \"data-slot\": \"split-text\",\n      // Every unit is aria-hidden, so the text reaches assistive tech once as the root's accessible\n      // name — and stays in the DOM once, so textContent, innerText and a copy of the selection all\n      // return it once. role=generic (span) and role=paragraph (p) prohibit aria-label, so those two\n      // are exposed as an image with a text alternative; a heading can name itself.\n      role: Tag === \"span\" || Tag === \"p\" ? \"img\" : undefined,\n      \"aria-label\": children,\n      className: cn(\"inline-block\", className),\n      ...props,\n    },\n    units.map(({ text, order }, index) =>\n      // Whitespace is emitted as a bare text node, not as a unit of its own: inside an inline-block\n      // a lone collapsible space is trimmed to zero width and words run together, while an\n      // inline-block of U+00A0 is never trimmed at a line end and pushes wrapped centred lines\n      // off-centre. Between the wrappers it is an ordinary space in the parent's inline formatting\n      // context — full width mid-line, trimmed at a line end.\n      order < 0 ? (\n        text\n      ) : (\n        <span key={index} aria-hidden className=\"inline-block overflow-hidden align-bottom\">\n          <motion.span\n            data-split-unit\n            className=\"inline-block\"\n            variants={UNIT}\n            // `initial` is serialised into the SSR markup; the registry's scripting-off rule shows the\n            // text where no script will ever run to reveal it.\n            initial=\"hidden\"\n            animate={inView ? \"shown\" : \"hidden\"}\n            transition={transitionFor(order)}\n          >\n            {text}\n          </motion.span>\n        </span>\n      )\n    )\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@media (scripting: none)": {
      "[data-split-unit]": {
        "opacity": "1 !important",
        "transform": "none !important"
      }
    }
  },
  "meta": {
    "added": "2026-09-15"
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}