{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "typewriter-text",
  "title": "Typewriter Text",
  "description": "A staged line that types itself with a human rhythm, the whole text laid out from the start so lines never reflow, and a caret that goes away when it is done.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/split-text.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/typewriter-text.tsx",
      "content": "import type * as React from \"react\"\nimport { useMemo, useRef } from \"react\"\nimport { useInView, useReducedMotion } from \"motion/react\"\nimport { typingTimeline, DEFAULT_STEP_MS } from \"@/registry/miseui/ui/typewriter-math\"\nimport { useLoopHold, useTypewriterPlay } from \"@/registry/miseui/ui/typewriter-play\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype TypewriterTextVariant = \"line\" | \"block\" | \"underscore\" | \"field\"\ntype TypewriterTextEffect = \"loop\"\ntype TypewriterTextTrigger = \"view\" | \"mount\"\ntype TypewriterTextTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\"\n\nconst DEFAULT_SEED = 5\nconst VIEW_MARGIN = \"0px 0px -10% 0px\"\n\n/**\n * The caret is out of flow on purpose. An inline-block would add a break opportunity in the middle of a word,\n * and the line would jump as the caret moved across it. With no offsets it stays where the text ends.\n */\nconst CARET_CLASS: Record<TypewriterTextVariant, string> = {\n  line: \"w-[2px] h-[1.1em] mt-[calc((1lh-1.1em)/2)] bg-current\",\n  block: \"w-[1ch] h-[1lh] bg-foreground\",\n  underscore: \"w-[1ch] h-[2px] mt-[calc(0.5lh+0.42em)] bg-current\",\n  field: \"w-[2px] h-[1.1em] mt-[calc((1lh-1.1em)/2)] bg-mise\",\n}\n\nconst ROOT_CLASS: Record<TypewriterTextVariant, string> = {\n  line: \"\",\n  block: \"font-mono\",\n  underscore: \"\",\n  field: \"block\",\n}\n\nconst FIELD_BOX = \"flex min-h-10 w-full items-center min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm text-muted-foreground shadow-xs\"\n\nfunction TypedRun({ text, variant }: { text: string; variant: TypewriterTextVariant }) {\n  return (\n    <>\n      <span data-slot=\"typewriter-text-typed\">{text}</span>\n      <span data-slot=\"typewriter-text-caret\" className={cn(\"pointer-events-none absolute\", CARET_CLASS[variant])} style={{ opacity: 0 }} />\n      {/* Laid out but unseen, so the wrap of every line is settled before the first letter lands. */}\n      <span data-slot=\"typewriter-text-rest\" className=\"invisible\" />\n    </>\n  )\n}\n\n/** The run sits on top of every phrase laid out invisibly, so the longest one holds the space for all of them. */\nfunction LoopStack({ phrases, variant }: { phrases: readonly string[]; variant: TypewriterTextVariant }) {\n  return (\n    <span className=\"grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)]\">\n      {phrases.map((phrase, index) => (\n        <span key={`${index}:${phrase}`} data-slot=\"typewriter-text-reserve\" className=\"invisible [grid-area:1/1]\">\n          {phrase}\n        </span>\n      ))}\n      <span className=\"[grid-area:1/1]\">\n        <TypedRun text={phrases[0]} variant={variant} />\n      </span>\n    </span>\n  )\n}\n\ninterface VisualBodyProps {\n  list: readonly string[]\n  loop: boolean\n  variant: TypewriterTextVariant\n  prompt: string\n}\n\nfunction VisualBody({ list, loop, variant, prompt }: VisualBodyProps) {\n  const body = loop ? <LoopStack phrases={list} variant={variant} /> : <TypedRun text={list[0]} variant={variant} />\n  return (\n    <>\n      {variant === \"block\" ? (\n        <span data-slot=\"typewriter-text-prompt\" className=\"shrink-0 text-muted-foreground select-none\">\n          {prompt}\n        </span>\n      ) : null}\n      {variant === \"block\" || (variant === \"field\" && !loop) ? <span className=\"min-w-0\">{body}</span> : body}\n    </>\n  )\n}\n\ntype TypewriterSource =\n  | { effect?: undefined; text: string; phrases?: never; paused?: never }\n  | {\n      /** `loop` types, waits, deletes and moves to the next phrase. Holds still off screen, in a hidden tab, and on hover or focus. */\n      effect: TypewriterTextEffect\n      phrases: readonly string[]\n      text?: never\n      /** Holds the loop on the letter it reached. */\n      paused?: boolean\n    }\n\ntype TypewriterTextProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> &\n  TypewriterSource & {\n    /** The element to render. A heading stays a heading. */\n    as?: TypewriterTextTag\n    /** `line` a thin caret, `block` monospace with a block caret and a prompt, `underscore` a low bar, `field` inside an input-looking box as a live placeholder. */\n    variant?: TypewriterTextVariant\n    /** `view` starts when first seen, `mount` right away. */\n    trigger?: TypewriterTextTrigger\n    /** The same seed gives the same rhythm. */\n    seed?: number\n    /** Average time per letter in ms. Long text is sped up to stay short. */\n    stepMs?: number\n    /** The mark in front of `block`. */\n    prompt?: string\n  }\n\nfunction TypewriterText({\n  text,\n  phrases,\n  effect,\n  paused = false,\n  as: Tag = \"span\",\n  variant = \"line\",\n  trigger = \"view\",\n  seed = DEFAULT_SEED,\n  stepMs = DEFAULT_STEP_MS,\n  prompt = \"$\",\n  className,\n  ...props\n}: TypewriterTextProps) {\n  const loop = effect === \"loop\"\n  const list = phrases && phrases.length > 0 ? phrases : [text ?? \"\"]\n  // An inline array is a new value every render, and a new timeline would restart the typing.\n  const signature = JSON.stringify(list)\n  const timeline = useMemo(() => typingTimeline(JSON.parse(signature) as string[], { seed, stepMs }, loop), [signature, seed, stepMs, loop])\n  const reduceMotion = useReducedMotion() ?? false\n  const visualRef = useRef<HTMLSpanElement>(null)\n  // A loop keeps watching so it can stop off screen; a single run only needs its first sighting.\n  const seen = useInView(visualRef, { once: !loop, margin: VIEW_MARGIN })\n  const held = useLoopHold(visualRef, loop && !reduceMotion)\n  const running = loop ? seen && !held && !paused : trigger === \"mount\" || seen\n  const runKey = `${variant}:${reduceMotion}`\n  useTypewriterPlay({ visualRef, timeline, running, reduceMotion, runKey })\n\n  return (\n    <Tag\n      {...props}\n      data-slot=\"typewriter-text\"\n      data-variant={variant}\n      data-effect={effect}\n      // A loop lasts longer than five seconds, so keyboard users need a way to stop it too.\n      tabIndex={loop ? 0 : props.tabIndex}\n      className={cn(\"relative\", ROOT_CLASS[variant], loop && \"block rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\", className)}\n    >\n      {/* Letters arriving one at a time would be read as stutter. A loop lists its phrases once. */}\n      <span className=\"sr-only select-none\">{list.join(\", \")}</span>\n      <span\n        ref={visualRef}\n        aria-hidden=\"true\"\n        data-slot=\"typewriter-text-visual\"\n        className={cn(variant === \"block\" && \"inline-flex max-w-full gap-2\", variant === \"field\" && FIELD_BOX)}\n      >\n        {/* Inside the observed element, because useInView keeps watching the node it first got. */}\n        <VisualBody key={runKey} list={list} loop={loop} variant={variant} prompt={prompt} />\n      </span>\n    </Tag>\n  )\n}\n\nexport { TypewriterText, type TypewriterTextProps, type TypewriterTextVariant, type TypewriterTextEffect, type TypewriterTextTrigger }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/typewriter-play.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\"\nimport { typingFrame, type TypingTimeline } from \"@/registry/miseui/ui/typewriter-math\"\n\n/** Long gaps mean the page slept. Resuming should continue from where it stopped, not jump ahead. */\nconst MAX_FRAME_GAP_MS = 64\n\n/** Writes only on change, so most frames touch nothing. */\nfunction write(node: HTMLElement | null, text: string) {\n  if (node && node.textContent !== text) node.textContent = text\n}\n\nfunction paint(visual: HTMLElement, timeline: TypingTimeline, clock: number) {\n  const frame = typingFrame(timeline, clock)\n  const { glyphs } = timeline.segments[frame.phrase]\n  write(visual.querySelector(\"[data-slot='typewriter-text-typed']\"), glyphs.slice(0, frame.count).join(\"\"))\n  write(visual.querySelector(\"[data-slot='typewriter-text-rest']\"), glyphs.slice(frame.count).join(\"\"))\n  const caret = visual.querySelector<HTMLElement>(\"[data-slot='typewriter-text-caret']\")\n  if (caret) caret.style.opacity = String(frame.caret)\n  return frame.done\n}\n\ninterface PlayOptions {\n  visualRef: React.RefObject<HTMLElement | null>\n  timeline: TypingTimeline\n  running: boolean\n  reduceMotion: boolean\n  /** A new structure means a fresh run. */\n  runKey: string\n}\n\n/** One frame loop on one clock. The clock only advances while running, so a pause holds the exact letter it stopped on. */\nfunction useTypewriterPlay({ visualRef, timeline, running, reduceMotion, runKey }: PlayOptions) {\n  const clock = useRef(0)\n\n  // Before paint, so the finished text from the first render never flashes ahead of the typing.\n  useLayoutEffect(() => {\n    clock.current = 0\n    if (!reduceMotion && visualRef.current) paint(visualRef.current, timeline, 0)\n  }, [visualRef, timeline, reduceMotion, runKey])\n\n  useEffect(() => {\n    const visual = visualRef.current\n    if (reduceMotion || !running || !visual) return\n    let frame = 0\n    let last: number | null = null\n    const tick = (now: number) => {\n      clock.current += Math.min(now - (last ?? now), MAX_FRAME_GAP_MS)\n      last = now\n      frame = paint(visual, timeline, clock.current) ? 0 : requestAnimationFrame(tick)\n    }\n    frame = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(frame)\n  }, [visualRef, timeline, running, reduceMotion, runKey])\n}\n\n/** True while something asks a loop to hold still: a pointer on it, focus in it, or a hidden tab. */\nfunction useLoopHold(anchor: React.RefObject<HTMLElement | null>, enabled: boolean) {\n  const [hovered, setHovered] = useState(false)\n  const [focused, setFocused] = useState(false)\n  const [hidden, setHidden] = useState(false)\n\n  useEffect(() => {\n    const root = anchor.current?.closest<HTMLElement>(\"[data-slot='typewriter-text']\")\n    if (!enabled || !root) return\n    const onEnter = () => setHovered(true)\n    const onLeave = () => setHovered(false)\n    const onFocus = () => setFocused(true)\n    const onBlur = () => setFocused(false)\n    const onVisibility = () => setHidden(document.hidden)\n    root.addEventListener(\"mouseenter\", onEnter)\n    root.addEventListener(\"mouseleave\", onLeave)\n    root.addEventListener(\"focusin\", onFocus)\n    root.addEventListener(\"focusout\", onBlur)\n    document.addEventListener(\"visibilitychange\", onVisibility)\n    return () => {\n      root.removeEventListener(\"mouseenter\", onEnter)\n      root.removeEventListener(\"mouseleave\", onLeave)\n      root.removeEventListener(\"focusin\", onFocus)\n      root.removeEventListener(\"focusout\", onBlur)\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n    }\n  }, [anchor, enabled])\n\n  return hovered || focused || hidden\n}\n\nexport { useLoopHold, useTypewriterPlay }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/typewriter-math.ts",
      "content": "import { splitText } from \"@/registry/miseui/ui/split-text-math\"\n\nconst DEFAULT_STEP_MS = 42\n/** A staged sentence should not keep someone waiting, so long text types faster instead of longer. */\nconst MAX_TYPING_MS = 2600\nconst SENTENCE_PAUSE_MS = 260\nconst CLAUSE_PAUSE_MS = 130\nconst HOLD_MS = 1800\nconst GAP_MS = 260\nconst DELETE_STEP_MS = 16\nconst MAX_DELETE_MS = 420\n/** About one blink a second, like a real text caret, and well under the three-flash limit. */\nconst BLINK_MS = 530\n/** On, off, on, then gone. */\nconst CARET_LINGER_MS = BLINK_MS * 3\n\nconst SENTENCE_END = /[.!?…。！？]/\nconst CLAUSE_END = /[,;:、，]/\n\ninterface TypingOptions {\n  seed: number\n  stepMs?: number\n}\n\ninterface TypingSegment {\n  glyphs: readonly string[]\n  /** When each glyph appears, measured from the start of this phrase. */\n  schedule: readonly number[]\n  startAt: number\n  typedAt: number\n  heldAt: number\n  deletedAt: number\n  endAt: number\n}\n\ninterface TypingTimeline {\n  segments: readonly TypingSegment[]\n  totalMs: number\n  loop: boolean\n}\n\ninterface TypingFrame {\n  phrase: number\n  count: number\n  /** 1 shows the caret, 0 hides it. */\n  caret: 0 | 1\n  /** A run that does not loop is over and needs no more frames. */\n  done: boolean\n}\n\nfunction typingUnit(seed: number, index: number) {\n  const mixed = Math.imul((seed + 1) ^ Math.imul(index + 1, 0x9e3779b1), 0xc2b2ae3d)\n  return (((mixed ^ (mixed >>> 15)) >>> 0) % 1000) / 1000\n}\n\n/** Graphemes with the spaces kept, so a Hangul syllable or an emoji family arrives as one keystroke. */\nfunction typingGlyphs(text: string): readonly string[] {\n  const words = splitText(text, \"chars\")\n  return words.flatMap((word, position) => {\n    const pieces = word.pieces.map((piece) => piece.text)\n    return position < words.length - 1 ? [...pieces, \" \"] : pieces\n  })\n}\n\n/** Pause after a glyph: people stop at punctuation and hesitate between words, and no two keystrokes are the same. */\nfunction keyGap(previous: string, index: number, { seed, stepMs = DEFAULT_STEP_MS }: TypingOptions) {\n  const jitter = stepMs * (0.6 + 0.8 * typingUnit(seed, index))\n  if (SENTENCE_END.test(previous)) return jitter + SENTENCE_PAUSE_MS\n  if (CLAUSE_END.test(previous)) return jitter + CLAUSE_PAUSE_MS\n  return previous === \" \" ? jitter + stepMs * 0.5 : jitter\n}\n\n/** The first glyph lands at once, so the very first frame already shows text. */\nfunction typingSchedule(glyphs: readonly string[], options: TypingOptions): readonly number[] {\n  const raw = glyphs.reduce<readonly number[]>((times, _, index) => {\n    if (index === 0) return [0]\n    return [...times, times[index - 1] + keyGap(glyphs[index - 1], index, options)]\n  }, [])\n  const last = raw.at(-1) ?? 0\n  const scale = last > MAX_TYPING_MS ? MAX_TYPING_MS / last : 1\n  return raw.map((time) => Math.round(time * scale))\n}\n\nfunction countAt(schedule: readonly number[], elapsed: number) {\n  const next = schedule.findIndex((time) => time > elapsed)\n  return next === -1 ? schedule.length : next\n}\n\nfunction deleteStep(count: number) {\n  return count > 0 ? Math.min(DELETE_STEP_MS, MAX_DELETE_MS / count) : 0\n}\n\nfunction typingTimeline(phrases: readonly string[], options: TypingOptions, loop: boolean): TypingTimeline {\n  const segments = phrases.reduce<readonly TypingSegment[]>((built, phrase, index) => {\n    const glyphs = typingGlyphs(phrase)\n    const schedule = typingSchedule(glyphs, { ...options, seed: options.seed + index })\n    const startAt = built.at(-1)?.endAt ?? 0\n    const typedAt = startAt + (schedule.at(-1) ?? 0)\n    const heldAt = typedAt + HOLD_MS\n    const deletedAt = heldAt + glyphs.length * deleteStep(glyphs.length)\n    return [...built, { glyphs, schedule, startAt, typedAt, heldAt, deletedAt, endAt: deletedAt + GAP_MS }]\n  }, [])\n  return { segments, totalMs: segments.at(-1)?.endAt ?? 0, loop }\n}\n\nfunction blink(idleMs: number): 0 | 1 {\n  return Math.floor(idleMs / BLINK_MS) % 2 === 0 ? 1 : 0\n}\n\nfunction onceFrame(segment: TypingSegment, elapsed: number): TypingFrame {\n  const idle = elapsed - segment.typedAt\n  const count = countAt(segment.schedule, elapsed)\n  if (idle <= 0) return { phrase: 0, count, caret: 1, done: false }\n  if (idle >= CARET_LINGER_MS) return { phrase: 0, count, caret: 0, done: true }\n  return { phrase: 0, count, caret: blink(idle), done: false }\n}\n\nfunction loopFrame(segments: readonly TypingSegment[], clock: number): TypingFrame {\n  const phrase = Math.max(0, segments.findIndex((segment) => clock < segment.endAt))\n  const segment = segments[phrase]\n  const { glyphs, schedule, startAt, typedAt, heldAt, deletedAt } = segment\n  if (clock <= typedAt) return { phrase, count: countAt(schedule, clock - startAt), caret: 1, done: false }\n  if (clock < heldAt) return { phrase, count: glyphs.length, caret: blink(clock - typedAt), done: false }\n  if (clock < deletedAt) {\n    const removed = Math.floor((clock - heldAt) / deleteStep(glyphs.length))\n    return { phrase, count: Math.max(0, glyphs.length - removed), caret: 1, done: false }\n  }\n  return { phrase, count: 0, caret: 1, done: false }\n}\n\n/** What to show at a moment. Pure, so the whole cadence is testable without a frame. */\nfunction typingFrame(timeline: TypingTimeline, clock: number): TypingFrame {\n  if (!timeline.loop) return onceFrame(timeline.segments[0], clock)\n  return loopFrame(timeline.segments, timeline.totalMs > 0 ? clock % timeline.totalMs : 0)\n}\n\nexport { BLINK_MS, CARET_LINGER_MS, DEFAULT_STEP_MS, MAX_TYPING_MS, countAt, typingFrame, typingGlyphs, typingSchedule, typingTimeline, type TypingFrame, type TypingTimeline }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}