Streaming-text renderer where already-arrived content never re-animates — only the trailing, unterminated edge carries a muted caret and settles when its markdown span closes.
npx shadcn add https://design.helpmarq.com /r/streaming-markdown-caret.jsonregistry/core/streaming-markdown-caret/component.tsx"use client";
import { useEffect, useMemo, useState } from "react";
// Tiny hand-written scanner — no markdown dependency. A real parser (e.g.
// streamdown) rebuilds its AST from the whole string on every token, and a
// fresh AST means fresh React elements for text that already rendered
// correctly last frame: the entire block remounts and shimmers. This
// scanner exists specifically so already-closed segments keep the same key
// (their fixed character offset) forever and are never touched again.
type Segment = { type: "text" | "bold" | "code"; content: string; start: number };
function parseKerf(source: string): { segments: Segment[]; tail: string } {
const segments: Segment[] = [];
let i = 0;
let runStart = 0;
const n = source.length;
while (i < n) {
if (source[i] === "*" && source[i + 1] === "*") {
const close = source.indexOf("**", i + 2);
if (close === -1) {
if (i > runStart) segments.push({ type: "text", content: source.slice(runStart, i), start: runStart });
runStart = i;
break;
}
if (i > runStart) segments.push({ type: "text", content: source.slice(runStart, i), start: runStart });
segments.push({ type: "bold", content: source.slice(i + 2, close), start: i });
i = close + 2;
runStart = i;
continue;
}
if (source[i] === "`") {
const close = source.indexOf("`", i + 1);
if (close === -1) {
if (i > runStart) segments.push({ type: "text", content: source.slice(runStart, i), start: runStart });
runStart = i;
break;
}
if (i > runStart) segments.push({ type: "text", content: source.slice(runStart, i), start: runStart });
segments.push({ type: "code", content: source.slice(i + 1, close), start: i });
i = close + 1;
runStart = i;
continue;
}
i++;
}
return { segments, tail: source.slice(runStart) };
}
export interface KerfCaretProps {
/**
* Full text accumulated so far. Append new characters as they arrive —
* never mutate or replace earlier characters, or the offset-based keys
* that keep the stable prefix from re-animating no longer hold.
*/
text: string;
/** true while more content may still arrive. Controls the trailing caret and the sr-only status. */
streaming?: boolean;
className?: string;
}
export function KerfCaret({ text, streaming = true, className = "" }: KerfCaretProps) {
const { segments, tail } = useMemo(() => parseKerf(text), [text]);
const [status, setStatus] = useState("");
useEffect(() => {
if (streaming) setStatus("Generating response…");
else if (text.length > 0) setStatus("Response complete.");
}, [streaming]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<span className={`relative whitespace-pre-wrap ${className}`}>
<style>{`
@keyframes ns-kerf-settle { from { opacity: .45 } to { opacity: 1 } }
.ns-kerf-settle { animation: ns-kerf-settle 120ms cubic-bezier(.16,1,.3,1); }
@keyframes ns-kerf-blink { 0%, 50% { opacity: 1 } 50.01%, 100% { opacity: .25 } }
.ns-streaming-markdown-caret { animation: ns-kerf-blink 1s steps(1, end) infinite; }
@media (prefers-reduced-motion: reduce) {
.ns-kerf-settle { animation: none; }
.ns-streaming-markdown-caret { animation: none; opacity: .55; }
}
`}</style>
{segments.map((seg) => {
if (seg.type === "bold") {
return (
<strong key={`bold-${seg.start}`} className="ns-kerf-settle font-semibold">
{seg.content}
</strong>
);
}
if (seg.type === "code") {
return (
<code
key={`code-${seg.start}`}
className="ns-kerf-settle rounded-sm border border-border bg-surface px-1 py-0.5 font-mono text-[0.9em]"
>
{seg.content}
</code>
);
}
return <span key={`text-${seg.start}`}>{seg.content}</span>;
})}
<span key="tail">{tail}</span>
{streaming && (
<span
key="caret"
aria-hidden
className="ns-streaming-markdown-caret ml-0.5 inline-block h-[1em] w-[0.55em] translate-y-[0.15em] rounded-[2px] bg-muted align-text-bottom"
/>
)}
<span key="status" role="status" aria-live="polite" className="sr-only">
{status}
</span>
</span>
);
}
A streaming-text renderer for live, unknown-length arrival (LLM/chat output), not an entrance effect on an already-known string. The only prop that matters is `text`: pass the full accumulated string so far on every render and keep appending to it — never mutate or replace earlier characters, or the offset-based keys this relies on stop holding. PARSING: a tiny hand-written scanner (deliberately zero dependencies — a real markdown parser, streamdown included, rebuilds its whole AST from the string on every token, which means fresh React elements for text that already rendered correctly last frame, i.e. the entire block remounts and shimmers; the one thing this component exists to prevent) recognizes only `**bold**` and `` `code` ``, greedily matching each opening delimiter against the next matching close. Everything before the first still-open delimiter (or the whole string, if none) is split into closed segments keyed by their fixed character offset in the source string; offsets never shift because text is append-only, so React never remounts a stable segment and it never re-animates or reflows. Whatever follows an unterminated `**`/`` ` `` — or, absent one, the growing plain run at the very end — is the live tail: rendered as literal characters, dangling delimiter included, so a stray unterminated marker never garbles the text on either side of it. STABLE VS LIVE: when a later chunk supplies the matching close delimiter, the run that used to be raw tail becomes a real `<strong>`/`<code>` element for the first time — a genuinely new key the reconciler has never seen — so it mounts fresh and plays a 120ms ease-out-expo opacity settle (0.45→1); every segment rendered before it keeps its same DOM node and key, untouched. A muted `--muted` block caret (CSS steps() blink) sits at the very end of the tail while `streaming` is true and is omitted once it flips false. SCROLL: the component never calls scrollIntoView or steals focus, and only ever appends past the end of already-rendered nodes, so it cooperates with the browser's native scroll anchoring instead of fighting it — a consumer's chat log won't get yanked as tokens land; scrolling that log to the bottom, if wanted, is the consumer's job, not this component's. ACCESSIBILITY: the visible text is real text, not decorative glyph spans standing in for a label, so a screen reader's normal virtual cursor already reads whatever has arrived at any time. Token-by-token aria-live would be unusable noise, so instead a single sr-only role=status region announces exactly two coarse state changes — 'Generating response…' on start, 'Response complete.' on end — never the content itself. Under prefers-reduced-motion the settle flash and the caret blink are both disabled (the caret renders as a steady static block) but nothing about legibility depends on either animation running. Zero runtime dependencies.