{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-text",
  "title": "Split Text",
  "description": "A headline that arrives letter by letter or word by word, with the real sentence kept for assistive tech and a table of presets to extend.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/split-text.tsx",
      "content": "import type * as React from \"react\"\nimport { useRef } from \"react\"\nimport { motion, useInView, useReducedMotion } from \"motion/react\"\nimport {\n  SPLIT_PRESETS,\n  pieceCount,\n  pieceDelay,\n  splitText,\n  type SplitBy,\n  type SplitOrigin,\n  type SplitPiece,\n  type SplitPreset,\n  type SplitTextVariant,\n} from \"@/registry/miseui/ui/split-text-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype SplitTextEffect = \"center\"\ntype SplitTextTrigger = \"view\" | \"mount\"\ntype SplitTextTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\"\n\n/** Keeps a headline from starting while its last line is still under the fold. */\nconst VIEW_MARGIN = \"0px 0px -10% 0px\"\n\ninterface PieceProps {\n  piece: SplitPiece\n  preset: SplitPreset\n  shown: boolean\n  /** Seconds. */\n  delay: number\n}\n\nfunction Piece({ piece, preset, shown, delay }: PieceProps) {\n  const moving = (\n    <motion.span\n      data-slot=\"split-text-piece\"\n      className=\"inline-block\"\n      initial={preset.hidden}\n      animate={shown ? preset.shown : preset.hidden}\n      transition={{ ...preset.transition, delay }}\n    >\n      {piece.text}\n    </motion.span>\n  )\n  if (!preset.masked) return moving\n  return (\n    // The extra bottom room keeps descenders inside the clip; the negative margin gives the line height back.\n    <span data-slot=\"split-text-mask\" className=\"inline-block overflow-y-clip pb-[0.14em] -mb-[0.14em] align-baseline\">\n      {moving}\n    </span>\n  )\n}\n\ninterface SplitPiecesProps {\n  text: string\n  variant: SplitTextVariant\n  by: SplitBy\n  origin: SplitOrigin\n  shown: boolean\n  delay: number\n}\n\nfunction SplitPieces({ text, variant, by, origin, shown, delay }: SplitPiecesProps) {\n  const words = splitText(text, by)\n  const count = pieceCount(words)\n  return (\n    <>\n      {words.map((word, position) => (\n        <span key={word.key}>\n          {/* A word holds together, so a line never breaks in the middle of one. */}\n          <span data-slot=\"split-text-word\" className=\"inline-block whitespace-nowrap\">\n            {word.pieces.map((piece) => (\n              <Piece\n                key={piece.index}\n                piece={piece}\n                preset={SPLIT_PRESETS[variant]}\n                shown={shown}\n                delay={delay + pieceDelay(piece.index, count, by, origin) / 1000}\n              />\n            ))}\n          </span>\n          {position < words.length - 1 ? \" \" : null}\n        </span>\n      ))}\n    </>\n  )\n}\n\ntype SplitTextProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  text: string\n  /** The element to render. A heading stays a heading. */\n  as?: SplitTextTag\n  /** `rise` out from behind a line, `fade` a quiet arrival, `blur` from out of focus. */\n  variant?: SplitTextVariant\n  /** `center` starts the wave from the middle, for a centred headline. */\n  effect?: SplitTextEffect\n  /** Split by grapheme or by word. Long text reads better by word. */\n  by?: SplitBy\n  /** `view` plays once when first seen, `mount` plays right away, for what is on screen at load. */\n  trigger?: SplitTextTrigger\n  /** Seconds to wait before starting, to follow something else in. */\n  delay?: number\n}\n\nfunction SplitText({ text, as: Tag = \"span\", variant = \"rise\", effect, by = \"chars\", trigger = \"view\", delay = 0, className, ...props }: SplitTextProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const ref = useRef<HTMLSpanElement>(null)\n  const seen = useInView(ref, { once: true, margin: VIEW_MARGIN })\n  const root = { ...props, \"data-slot\": \"split-text\", \"data-variant\": variant, \"data-effect\": effect }\n  if (reduceMotion) {\n    return (\n      <Tag {...root} data-reduced=\"true\" className={className}>\n        {text}\n      </Tag>\n    )\n  }\n  return (\n    <Tag {...root} className={cn(\"relative\", className)}>\n      {/* Spans of single letters are read out one letter at a time, so assistive tech gets the real sentence instead. */}\n      <span className=\"sr-only select-none\">{text}</span>\n      <span ref={ref} key={`${variant}:${by}:${text}`} aria-hidden=\"true\" data-slot=\"split-text-visual\">\n        <SplitPieces\n          text={text}\n          variant={variant}\n          by={by}\n          origin={effect === \"center\" ? \"center\" : \"start\"}\n          shown={trigger === \"mount\" || seen}\n          delay={delay}\n        />\n      </span>\n    </Tag>\n  )\n}\n\nexport {\n  SplitText,\n  SPLIT_PRESETS,\n  pieceDelay,\n  splitText,\n  type SplitBy,\n  type SplitOrigin,\n  type SplitPreset,\n  type SplitTextProps,\n  type SplitTextVariant,\n  type SplitTextEffect,\n  type SplitTextTrigger,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/split-text-math.ts",
      "content": "import { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\n\ntype SplitBy = \"chars\" | \"words\"\ntype SplitOrigin = \"start\" | \"center\"\n\n/** What one piece looks like before and after. A new text effect is one more entry here. */\ninterface SplitPreset {\n  hidden: Record<string, string | number>\n  shown: Record<string, string | number>\n  /** Clip each piece to its own line box, so it comes out from behind an edge instead of fading through the text above. */\n  masked: boolean\n  transition: typeof SPRING_DEFAULT\n}\n\nconst SPLIT_PRESETS = {\n  rise: {\n    hidden: { y: \"110%\" },\n    shown: { y: \"0%\" },\n    masked: true,\n    transition: { ...SPRING_DEFAULT, duration: 0.6 },\n  },\n  fade: {\n    hidden: { opacity: 0 },\n    shown: { opacity: 1 },\n    masked: false,\n    transition: { ...SPRING_DEFAULT, duration: 0.5 },\n  },\n  blur: {\n    hidden: { opacity: 0, filter: \"blur(8px)\" },\n    shown: { opacity: 1, filter: \"blur(0px)\" },\n    masked: false,\n    transition: { ...SPRING_DEFAULT, duration: 0.6 },\n  },\n} as const satisfies Record<string, SplitPreset>\n\ntype SplitTextVariant = keyof typeof SPLIT_PRESETS\n\nconst STEP_MS: Record<SplitBy, number> = { chars: 24, words: 70 }\n/** A headline must not still be arriving when someone starts reading, so the wave has a ceiling. */\nconst MAX_WAVE_MS = 700\n\ninterface SplitPiece {\n  text: string\n  /** Position among all pieces of the text. */\n  index: number\n}\n\ninterface SplitWord {\n  key: string\n  pieces: readonly SplitPiece[]\n}\n\n/** Graphemes, so a Hangul syllable with combining marks or an emoji family stays one piece. */\nfunction toGraphemes(word: string) {\n  if (typeof Intl.Segmenter !== \"function\") return Array.from(word)\n  return Array.from(new Intl.Segmenter(undefined, { granularity: \"grapheme\" }).segment(word), (part) => part.segment)\n}\n\nfunction splitText(text: string, by: SplitBy): readonly SplitWord[] {\n  const words = text.trim().split(/\\s+/).filter(Boolean)\n  return words.reduce<{ result: readonly SplitWord[]; next: number }>(\n    ({ result, next }, word, position) => {\n      const parts = by === \"words\" ? [word] : toGraphemes(word)\n      const pieces = parts.map((part, offset) => ({ text: part, index: next + offset }))\n      return { result: [...result, { key: `${word}-${position}`, pieces }], next: next + parts.length }\n    },\n    { result: [], next: 0 },\n  ).result\n}\n\nfunction pieceCount(words: readonly SplitWord[]) {\n  return words.reduce((sum, word) => sum + word.pieces.length, 0)\n}\n\n/** Milliseconds before a piece starts. `center` runs outward, which suits a centred headline. */\nfunction pieceDelay(index: number, count: number, by: SplitBy, origin: SplitOrigin) {\n  const distance = origin === \"center\" ? Math.abs(index - (count - 1) / 2) : index\n  const reach = origin === \"center\" ? (count - 1) / 2 : count - 1\n  const step = reach > 0 ? Math.min(STEP_MS[by], MAX_WAVE_MS / reach) : 0\n  return Math.round(distance * step)\n}\n\nexport { SPLIT_PRESETS, pieceCount, pieceDelay, splitText, type SplitBy, type SplitOrigin, type SplitPiece, type SplitPreset, type SplitTextVariant, type SplitWord }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}