{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-cursor-loop",
  "title": "Text Cursor Loop",
  "description": "Cycles through a list of words with a per-character collapse/reveal transition and a trailing cursor glyph that wipes across the text between words.",
  "dependencies": ["motion"],
  "files": [
    {
      "path": "registry/primitives/texts/text-cursor-loop/index.tsx",
      "content": "/** biome-ignore-all lint/suspicious/noArrayIndexKey: characters are positional per rendered word, never reordered. */\n\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { animate, useReducedMotion } from \"motion/react\";\nimport {\n  type ElementType,\n  type ReactNode,\n  type Ref,\n  type RefCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\n\nconst COLLAPSE_CHAR_DURATION = 0.1;\nconst REVEAL_CHAR_DURATION = 0.15;\n/** ms — matches anime.js's `stagger(25, { ..., start: 100 })` on the reveal phase. */\nconst REVEAL_START_DELAY_MS = 100;\n/** seconds — matches anime.js's `delay: 50` on the collapse-phase cursor tween. */\nconst CURSOR_COLLAPSE_DELAY = 0.05;\nconst CURSOR_COLLAPSE_SCALE_FROM = 4;\nconst CURSOR_REVEAL_SCALE_FROM = 8;\n\nfunction round2(value: number) {\n  return Math.round(value * 100) / 100;\n}\n\nfunction easePowerIn(power: number) {\n  return (t: number) => t ** power;\n}\n\nfunction easePowerOut(power: number) {\n  return (t: number) => 1 - (1 - t) ** power;\n}\n\n// Matches anime.js's `stagger(..., { ease: \"in(3)\" })` on the char delay curve.\nconst EASE_STAGGER_IN_3 = easePowerIn(3);\n// Matches anime.js's implicit default ease (\"outQuad\") on the char opacity/scaleX tween.\nconst EASE_CHAR_DEFAULT = easePowerOut(2);\n// Matches anime.js's explicit `ease: \"out(3)\"` on the cursor tween.\nconst EASE_CURSOR_OUT_3 = easePowerOut(3);\n\n/**\n * Reproduces anime.js's `stagger(spacingMs, { from, ease: \"in(3)\", start })`\n * delay formula exactly (`scripts.js:31757`): each index's linear distance\n * from `fromIndex` is normalized, warped through the power-in(3) curve, then\n * scaled back up and multiplied by the per-step spacing.\n */\nfunction staggerDelayMs(\n  index: number,\n  total: number,\n  fromIndex: number,\n  spacingMs: number,\n  startMs: number\n) {\n  if (total <= 1) {\n    return startMs;\n  }\n  const maxValue = total - 1;\n  const distance = Math.abs(fromIndex - index);\n  const eased = EASE_STAGGER_IN_3(distance / maxValue) * maxValue;\n  return startMs + spacingMs * round2(eased);\n}\n\nexport type TextCursorLoopPhase = \"collapsing\" | \"idle\" | \"revealing\";\n\nexport interface TextCursorLoopClassNames {\n  /** The trailing cursor glyph. */\n  cursor?: string;\n}\n\nexport interface TextCursorLoopProps {\n  /** Root tag. @default \"span\" */\n  as?: ElementType;\n  /**\n   * Milliseconds between each character's stagger start.\n   * @default 25\n   */\n  charDelay?: number;\n  className?: string;\n  /** Per-slot class overrides: `cursor`. */\n  classNames?: TextCursorLoopClassNames;\n  /** The trailing glyph that wipes across the text between words. @default \".\" */\n  cursor?: ReactNode;\n  /**\n   * Milliseconds a word stays fully visible before collapsing.\n   * @default 1750\n   */\n  holdDuration?: number;\n  /** Wrap back to the first word after the last one. @default true */\n  loop?: boolean;\n  onIndexChange?: (index: number) => void;\n  ref?: Ref<HTMLElement>;\n  /** Set false to pause cycling. @default true */\n  trigger?: boolean;\n  /** Words cycled in order. */\n  words: string[];\n}\n\nfunction mergeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> {\n  return (node) => {\n    for (const ref of refs) {\n      if (!ref) {\n        continue;\n      }\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else {\n        ref.current = node;\n      }\n    }\n  };\n}\n\nfunction sleep(ms: number) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction getNextIndex(current: number, length: number, loop: boolean) {\n  const next = current + 1;\n  if (next < length) {\n    return next;\n  }\n  return loop ? 0 : null;\n}\n\ninterface CycleRefs {\n  cancelledRef: { current: boolean };\n  charRefs: { current: (HTMLSpanElement | null)[] };\n  charsWrapRef: { current: HTMLSpanElement | null };\n  cursorRef: { current: HTMLSpanElement | null };\n  indexRef: { current: number };\n}\n\ninterface CycleOptions {\n  charDelay: number;\n  holdDuration: number;\n  loop: boolean;\n  onIndexChange?: (index: number) => void;\n  setDisplayWord: (word: string) => void;\n  setPhase: (phase: TextCursorLoopPhase) => void;\n  words: string[];\n}\n\nfunction filterChars(refs: (HTMLSpanElement | null)[]): HTMLSpanElement[] {\n  return refs.filter((el): el is HTMLSpanElement => el !== null);\n}\n\n/** Runs one hold→collapse→swap→reveal cycle. Returns \"stop\" once cancelled or the sequence ends (non-looping). */\nasync function runCycleStep(\n  refs: CycleRefs,\n  options: CycleOptions\n): Promise<\"continue\" | \"stop\"> {\n  await sleep(options.holdDuration);\n  if (refs.cancelledRef.current) {\n    return \"stop\";\n  }\n\n  const wrap = refs.charsWrapRef.current;\n  const cursorEl = refs.cursorRef.current;\n  const oldChars = filterChars(refs.charRefs.current);\n  if (!(wrap && cursorEl) || oldChars.length === 0) {\n    return \"stop\";\n  }\n\n  options.setPhase(\"collapsing\");\n  await collapseWord(oldChars, cursorEl, wrap, options.charDelay);\n  if (refs.cancelledRef.current) {\n    return \"stop\";\n  }\n  // `wrap`'s layout box is still sized for the outgoing word here — the\n  // collapse animation only scaled chars visually, transforms don't\n  // affect layout — so this is the correct \"from\" width for revealWord's\n  // width tween below.\n  const oldWordWidth = wrap.getBoundingClientRect().width;\n\n  const nextIndex = getNextIndex(\n    refs.indexRef.current,\n    options.words.length,\n    options.loop\n  );\n  if (nextIndex === null) {\n    return \"stop\";\n  }\n  refs.indexRef.current = nextIndex;\n  options.onIndexChange?.(nextIndex);\n\n  refs.charRefs.current = [];\n  flushSync(() => {\n    options.setDisplayWord(options.words[nextIndex] ?? \"\");\n  });\n  if (refs.cancelledRef.current) {\n    return \"stop\";\n  }\n\n  const newChars = filterChars(refs.charRefs.current);\n  if (newChars.length === 0) {\n    options.setPhase(\"idle\");\n    return \"continue\";\n  }\n\n  options.setPhase(\"revealing\");\n  await revealWord(newChars, cursorEl, wrap, options.charDelay, oldWordWidth);\n  if (refs.cancelledRef.current) {\n    return \"stop\";\n  }\n  options.setPhase(\"idle\");\n  return \"continue\";\n}\n\nasync function collapseWord(\n  chars: HTMLSpanElement[],\n  cursorEl: HTMLSpanElement,\n  wrap: HTMLSpanElement,\n  charDelay: number\n) {\n  const width = wrap.getBoundingClientRect().width;\n  cursorEl.style.transformOrigin = \"100% 0%\";\n  const total = chars.length;\n  const lastIndex = total - 1;\n\n  await Promise.all([\n    ...chars.map((el, i) =>\n      animate(\n        el,\n        { opacity: 0, scaleX: 0 },\n        {\n          delay: staggerDelayMs(i, total, lastIndex, charDelay, 0) / 1000,\n          duration: COLLAPSE_CHAR_DURATION,\n          ease: EASE_CHAR_DEFAULT,\n        }\n      )\n    ),\n    animate(\n      cursorEl,\n      { scaleX: [CURSOR_COLLAPSE_SCALE_FROM, 1], x: -width },\n      {\n        delay: CURSOR_COLLAPSE_DELAY,\n        duration: (total * charDelay + 100) / 1000,\n        ease: EASE_CURSOR_OUT_3,\n      }\n    ),\n  ]);\n}\n\nasync function revealWord(\n  chars: HTMLSpanElement[],\n  cursorEl: HTMLSpanElement,\n  wrap: HTMLSpanElement,\n  charDelay: number,\n  fromWidth: number\n) {\n  for (const el of chars) {\n    el.style.opacity = \"0\";\n    el.style.transform = \"scaleX(0) translateX(10px)\";\n  }\n  // Transforms don't affect layout, so `wrap`'s box is still sized for the\n  // *old* word at this point — `scrollWidth` reads the new (untransformed)\n  // content's natural size regardless. Pinning `width` to the old size\n  // before animating it to the new one is what makes the container resize\n  // in step with the chars, instead of snapping the instant React commits\n  // the new word.\n  const toWidth = wrap.scrollWidth;\n  wrap.style.width = `${fromWidth}px`;\n  cursorEl.style.transformOrigin = \"0% 0%\";\n  const total = chars.length;\n  const cursorDuration = (total * charDelay + 75) / 1000;\n\n  await Promise.all([\n    ...chars.map((el, i) =>\n      animate(\n        el,\n        { opacity: 1, scaleX: 1, x: 0 },\n        {\n          delay:\n            staggerDelayMs(i, total, 0, charDelay, REVEAL_START_DELAY_MS) /\n            1000,\n          duration: REVEAL_CHAR_DURATION,\n          ease: EASE_CHAR_DEFAULT,\n        }\n      )\n    ),\n    animate(\n      cursorEl,\n      { scaleX: [CURSOR_REVEAL_SCALE_FROM, 1], x: [-fromWidth, 0] },\n      { duration: cursorDuration, ease: EASE_CURSOR_OUT_3 }\n    ),\n    animate(\n      wrap,\n      { width: toWidth },\n      { duration: cursorDuration, ease: EASE_CURSOR_OUT_3 }\n    ),\n  ]);\n\n  wrap.style.width = \"\";\n}\n\nexport function TextCursorLoop({\n  as,\n  charDelay = 25,\n  className,\n  classNames,\n  cursor = \".\",\n  holdDuration = 1750,\n  loop = true,\n  onIndexChange,\n  ref,\n  trigger = true,\n  words,\n}: TextCursorLoopProps) {\n  const Component = as ?? \"span\";\n  const prefersReducedMotion = useReducedMotion();\n\n  const rootRef = useRef<HTMLElement>(null);\n  const charsWrapRef = useRef<HTMLSpanElement>(null);\n  const charRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const cursorRef = useRef<HTMLSpanElement>(null);\n  const cancelledRef = useRef(false);\n  const indexRef = useRef(0);\n\n  const [displayWord, setDisplayWord] = useState(words[0] ?? \"\");\n  const [phase, setPhase] = useState<TextCursorLoopPhase>(\"idle\");\n\n  useEffect(() => {\n    if (prefersReducedMotion || !trigger || words.length < 2) {\n      return;\n    }\n\n    cancelledRef.current = false;\n    indexRef.current = 0;\n    setDisplayWord(words[0] ?? \"\");\n    setPhase(\"idle\");\n\n    const cycleRefs: CycleRefs = {\n      cancelledRef,\n      charRefs,\n      charsWrapRef,\n      cursorRef,\n      indexRef,\n    };\n    const cycleOptions: CycleOptions = {\n      charDelay,\n      holdDuration,\n      loop,\n      onIndexChange,\n      setDisplayWord,\n      setPhase,\n      words,\n    };\n\n    const runCycle = async () => {\n      let step: \"continue\" | \"stop\" = \"continue\";\n      while (step === \"continue\" && !cancelledRef.current) {\n        step = await runCycleStep(cycleRefs, cycleOptions);\n      }\n    };\n\n    runCycle();\n\n    return () => {\n      cancelledRef.current = true;\n    };\n  }, [\n    charDelay,\n    holdDuration,\n    loop,\n    onIndexChange,\n    prefersReducedMotion,\n    trigger,\n    words,\n  ]);\n\n  if (prefersReducedMotion) {\n    return (\n      <Component\n        aria-label={words[0]}\n        className={cn(\"relative inline-flex\", className)}\n        ref={mergeRefs(rootRef, ref)}\n      >\n        {words[0]}\n      </Component>\n    );\n  }\n\n  return (\n    <Component\n      aria-label={displayWord}\n      className={cn(\"relative inline-flex\", className)}\n      data-phase={phase}\n      ref={mergeRefs(rootRef, ref)}\n    >\n      <span aria-hidden=\"true\" className=\"inline-flex\" ref={charsWrapRef}>\n        {[...displayWord].map((char, index) => (\n          <span\n            className=\"inline-block\"\n            key={index}\n            ref={(el) => {\n              charRefs.current[index] = el;\n            }}\n            style={char === \" \" ? { whiteSpace: \"pre\" } : undefined}\n          >\n            {char}\n          </span>\n        ))}\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"inline-block transition-colors duration-200\",\n          classNames?.cursor\n        )}\n        data-phase={phase}\n        ref={cursorRef}\n      >\n        {cursor}\n      </span>\n    </Component>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/sora-ui/texts/text-cursor-loop.tsx"
    }
  ],
  "meta": {
    "keywords": [
      "text",
      "loop",
      "cycle",
      "cursor",
      "typewriter",
      "hero",
      "motion"
    ]
  },
  "type": "registry:ui"
}
