{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"terminal-text","type":"registry:ui","title":"TerminalText","description":"Terminal-style text streamer with per-character animation, blinking cursor, and glitch effect","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["text"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/terminal-text/TerminalText.tsx","content":"\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport \"./terminal-text.css\";\n\nexport interface TerminalTextProps {\n\tlines: string[];\n\tspeed?: number;\n\tdelay?: number;\n\tcursor?: boolean;\n\tcursorChar?: string;\n\tglitch?: boolean;\n\tclassName?: string;\n\tonComplete?: () => void;\n}\n\nconst GLITCH_GLYPHS = \"アイウエオ@#$%&!?░▒▓█▄▀■□▪▫\";\n\nexport function TerminalText({\n\tlines,\n\tspeed = 40,\n\tdelay = 0,\n\tcursor = true,\n\tcursorChar = \"█\",\n\tglitch = false,\n\tclassName,\n\tonComplete,\n}: TerminalTextProps) {\n\tconst [displayedLines, setDisplayedLines] = useState<string[]>([]);\n\tconst [done, setDone] = useState(false);\n\n\t// Latest-value refs so timeout callbacks read current values, matching the\n\t// Svelte closures over reactive props/state.\n\tconst displayedLinesRef = useRef(displayedLines);\n\tdisplayedLinesRef.current = displayedLines;\n\tconst glitchRef = useRef(glitch);\n\tglitchRef.current = glitch;\n\tconst onCompleteRef = useRef(onComplete);\n\tonCompleteRef.current = onComplete;\n\n\t// The stream reads the CURRENT list when it schedules, the way the source's\n\t// `streamLines()` closure reads the reactive `lines` prop.\n\tconst linesRef = useLiveRef(lines);\n\n\t// The stream is keyed on the CONTENT, never on the `lines` array itself. The\n\t// array is an identity that a call site like `lines={[\"a\", \"b\"]}` re-allocates\n\t// on every parent render; keying the effect on it would clear the pending\n\t// timeouts, wipe the typed-out text and restart the whole animation each time\n\t// the parent re-renders. The source restarts only when a tracked value really\n\t// changes, and an inline literal in a Svelte template is not one.\n\t// Serialised rather than joined, so `[\"ab\"]` and `[\"a\", \"b\"]` — which stream\n\t// differently — never collide on the same key.\n\t//\n\t// Keyed on `lines` REFERENCE identity via useMemo, not recomputed on every\n\t// call: the component body re-runs once per streamed character (each is\n\t// its own state update), and JSON.stringify over a long transcript on\n\t// every one of those internal renders is O(n²) in the stream length. A\n\t// fresh array from the parent (different identity) still re-serialises —\n\t// so equal-content-but-new-reference arrays keep comparing by content,\n\t// same as before — only re-renders that reuse the same array skip it.\n\tconst linesKey = useMemo(() => JSON.stringify(lines), [lines]);\n\n\t// Glitch bookkeeping never drives markup, so it lives in a ref, not state.\n\tconst glitchStateRef = useRef<{ lineIdx: number; charIdx: number; original: string } | null>(\n\t\tnull\n\t);\n\n\t// Timeout pool owned by the stream: the character schedule, the done tick and\n\t// the 100ms glitch restores. Cleared whenever the stream restarts\n\t// (lines/speed/delay change) and on unmount. The self-rescheduling glitch\n\t// chain is NOT in here — see the glitch effect below.\n\tconst timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);\n\n\tfunction scheduleTimeout(fn: () => void, ms: number) {\n\t\tconst id = setTimeout(fn, ms);\n\t\ttimeoutsRef.current.push(id);\n\t\treturn id;\n\t}\n\n\tfunction clearAllTimeouts() {\n\t\ttimeoutsRef.current.forEach(clearTimeout);\n\t\ttimeoutsRef.current = [];\n\t}\n\n\tfunction glitchOnce() {\n\t\tconst displayed = displayedLinesRef.current;\n\t\tif (displayed.length === 0) return;\n\n\t\t// Pick a random line with content\n\t\tconst linesWithContent = displayed.map((l, i) => ({ l, i })).filter(({ l }) => l.length > 0);\n\t\tif (linesWithContent.length === 0) return;\n\n\t\tconst { l, i } = linesWithContent[Math.floor(Math.random() * linesWithContent.length)]!;\n\t\tconst charIdx = Math.floor(Math.random() * l.length);\n\t\tconst original = l[charIdx]!;\n\t\tconst fakeGlyph = GLITCH_GLYPHS[Math.floor(Math.random() * GLITCH_GLYPHS.length)];\n\n\t\tglitchStateRef.current = { lineIdx: i, charIdx, original };\n\n\t\t// Temporarily replace char in display, restore after 100ms\n\t\tsetDisplayedLines((prev) =>\n\t\t\tprev.map((line, idx) => {\n\t\t\t\tif (idx !== i) return line;\n\t\t\t\treturn line.slice(0, charIdx) + fakeGlyph + line.slice(charIdx + 1);\n\t\t\t})\n\t\t);\n\n\t\tscheduleTimeout(() => {\n\t\t\tconst glitchState = glitchStateRef.current;\n\t\t\tif (glitchState && glitchState.lineIdx === i && glitchState.charIdx === charIdx) {\n\t\t\t\tsetDisplayedLines((prev) =>\n\t\t\t\t\tprev.map((line, idx) => {\n\t\t\t\t\t\tif (idx !== i) return line;\n\t\t\t\t\t\treturn line.slice(0, charIdx) + original + line.slice(charIdx + 1);\n\t\t\t\t\t})\n\t\t\t\t);\n\t\t\t\tglitchStateRef.current = null;\n\t\t\t}\n\t\t}, 100);\n\t}\n\n\t// The chain owns its own pending id and a cancelled flag, so its teardown\n\t// stops exactly the chain it started. Keeping it out of the stream's pool\n\t// means a lines/speed/delay restart no longer kills the loop, and a glitch\n\t// flip no longer leaves an orphaned chain running alongside the new one.\n\tfunction startGlitchLoop(chain: { cancelled: boolean; id: ReturnType<typeof setTimeout> | null }) {\n\t\tconst scheduleGlitch = () => {\n\t\t\t// Random interval between 2s and 4s\n\t\t\tconst interval = 2000 + Math.random() * 2000;\n\t\t\tchain.id = setTimeout(() => {\n\t\t\t\tif (chain.cancelled) return;\n\t\t\t\tif (glitchRef.current) glitchOnce();\n\t\t\t\tscheduleGlitch();\n\t\t\t}, interval);\n\t\t};\n\n\t\tscheduleGlitch();\n\t}\n\n\t// React only to lines/speed/delay — changing glitch won't restart the stream\n\tuseEffect(() => {\n\t\tclearAllTimeouts();\n\t\tsetDisplayedLines([]);\n\t\tdisplayedLinesRef.current = [];\n\t\tsetDone(false);\n\t\tglitchStateRef.current = null;\n\n\t\tlet totalDelay = delay;\n\n\t\tconst streamed = linesRef.current;\n\t\tfor (let lineIdx = 0; lineIdx < streamed.length; lineIdx++) {\n\t\t\tconst line = streamed[lineIdx]!;\n\n\t\t\t// Push empty line placeholder\n\t\t\tconst capturedIdx = lineIdx;\n\t\t\tscheduleTimeout(() => {\n\t\t\t\tsetDisplayedLines((prev) => [...prev, \"\"]);\n\t\t\t}, totalDelay);\n\n\t\t\t// Stream characters one by one\n\t\t\tfor (let charIdx = 0; charIdx < line.length; charIdx++) {\n\t\t\t\tconst capturedChar = charIdx;\n\t\t\t\tconst capturedLine = line;\n\t\t\t\ttotalDelay += speed;\n\t\t\t\tscheduleTimeout(() => {\n\t\t\t\t\tsetDisplayedLines((prev) =>\n\t\t\t\t\t\tprev.map((l, i) => (i === capturedIdx ? capturedLine.slice(0, capturedChar + 1) : l))\n\t\t\t\t\t);\n\t\t\t\t}, totalDelay);\n\t\t\t}\n\n\t\t\t// Small pause between lines\n\t\t\ttotalDelay += speed * 3;\n\t\t}\n\n\t\t// Mark done\n\t\tscheduleTimeout(() => {\n\t\t\tsetDone(true);\n\t\t\tonCompleteRef.current?.();\n\t\t}, totalDelay);\n\n\t\treturn () => {\n\t\t\tclearAllTimeouts();\n\t\t};\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [linesKey, speed, delay]);\n\n\t// React to glitch changes without restarting the stream\n\tuseEffect(() => {\n\t\tif (!glitch) {\n\t\t\tglitchStateRef.current = null;\n\t\t\treturn;\n\t\t}\n\n\t\tconst chain: { cancelled: boolean; id: ReturnType<typeof setTimeout> | null } = {\n\t\t\tcancelled: false,\n\t\t\tid: null,\n\t\t};\n\t\tstartGlitchLoop(chain);\n\n\t\treturn () => {\n\t\t\tchain.cancelled = true;\n\t\t\tif (chain.id !== null) clearTimeout(chain.id);\n\t\t};\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [glitch]);\n\n\treturn (\n\t\t<div className={cn(\"terminal-text font-mono text-sm leading-relaxed\", className)}>\n\t\t\t{displayedLines.map((line, i) => (\n\t\t\t\t<div key={i} className=\"min-h-[1.4em]\">\n\t\t\t\t\t<span>{line}</span>\n\t\t\t\t\t{cursor && i === displayedLines.length - 1 && !done ? (\n\t\t\t\t\t\t<span className=\"cursor-blink\">{cursorChar}</span>\n\t\t\t\t\t) : null}\n\t\t\t\t</div>\n\t\t\t))}\n\t\t\t{cursor && done ? (\n\t\t\t\t<div className=\"min-h-[1.4em]\">\n\t\t\t\t\t<span className=\"cursor-blink\">{cursorChar}</span>\n\t\t\t\t</div>\n\t\t\t) : null}\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/terminal-text/TerminalText.tsx"},{"path":"react/src/components/terminal-text/index.ts","content":"\"use client\";\n\nexport { TerminalText } from \"./TerminalText\";\nexport type { TerminalTextProps } from \"./TerminalText\";\n","type":"registry:ui","target":"@ui/fancy/components/terminal-text/index.ts"},{"path":"react/src/components/terminal-text/terminal-text.css","content":"/* .terminal-text anchor is port-added: the source's compiler-scoped <style>\n   had no root class, so the rules are anchored on the component slug to keep\n   the generic .cursor-blink name from leaking globally. The keyframe name\n   `blink` is kept identical to the source (part of the visual contract). */\n.terminal-text .cursor-blink {\n\tanimation: blink 1s step-end infinite;\n}\n\n@keyframes blink {\n\t0%,\n\t100% {\n\t\topacity: 1;\n\t}\n\t50% {\n\t\topacity: 0;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/terminal-text/terminal-text.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/terminal-text?framework=react"}}