Streaming Code Block
Generated code arrives line by line while the panel grows downward to fit it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Streaming Code Block
*
* A generated snippet arriving line by line. The panel grows downward to
* fit each new line, so the content already on screen never shifts and
* the page scroll never jumps under the reader.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so it reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `lines`, `filename`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StreamingCodeBlockProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Source lines, in order. Leading spaces are preserved. */
lines?: string[];
/** Shown in the panel header. */
filename?: string;
/** Language chip in the header. */
language?: string;
/** ms before the first line lands. */
leadInMs?: number;
/** Accent for keywords and the header chip. */
accent?: string;
/** Fires once the last line has landed. */
onComplete?: () => void;
};
type VariantConfig = {
/** ms between lines. */
cadenceMs: number;
/** px a landing line rises through. */
riseY: number;
/** Seconds a line takes to resolve. */
fadeSeconds: number;
/** Seconds the panel takes to grow by one line. */
growSeconds: number;
};
// Quality rule: nothing here springs. A code panel that overshoots its
// own height bounces every line already on screen, and the text is the
// payload — it rises a couple of pixels and never changes size. Variants
// differ in cadence and in how far a line travels, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Near-instant lines. For a long file where the cadence would
// otherwise become the whole experience.
subtle: { cadenceMs: 90, riseY: 0, fadeSeconds: 0.12, growSeconds: 0.14 },
// Readable cadence, a hair of travel. The all-purpose setting.
default: { cadenceMs: 150, riseY: 5, fadeSeconds: 0.2, growSeconds: 0.2 },
// Slower and more deliberate, for a short snippet presented as a
// result rather than as a file.
playful: { cadenceMs: 230, riseY: 9, fadeSeconds: 0.28, growSeconds: 0.26 },
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_LINES = [
"export async function loadOrders(page = 1) {",
' const response = await fetch(`/api/orders?page=${page}`);',
" if (!response.ok) {",
' throw new Error("Could not reach the orders service");',
" }",
" const { items, total } = await response.json();",
" return { items, total, page };",
"}",
];
const KEYWORDS = new Set([
"export",
"async",
"function",
"const",
"let",
"return",
"await",
"if",
"throw",
"new",
]);
const LINE_HEIGHT = 21;
const BODY_PADDING = 12;
type Token = { text: string; kind: "plain" | "keyword" | "text" };
/** Deliberately tiny: enough colour to read as code, no parser to ship. */
function tokenize(line: string): Token[] {
const tokens: Token[] = [];
const pattern = /("[^"]*"|`[^`]*`|'[^']*'|[A-Za-z_$][\w$]*)/g;
let cursor = 0;
let match = pattern.exec(line);
while (match) {
if (match.index > cursor) {
tokens.push({ text: line.slice(cursor, match.index), kind: "plain" });
}
const word = match[0];
const quoted = /^["'`]/.test(word);
tokens.push({
text: word,
kind: quoted ? "text" : KEYWORDS.has(word) ? "keyword" : "plain",
});
cursor = match.index + word.length;
match = pattern.exec(line);
}
if (cursor < line.length) {
tokens.push({ text: line.slice(cursor), kind: "plain" });
}
return tokens;
}
export default function StreamingCodeBlock({
variant = "default",
lines = DEFAULT_LINES,
filename = "orders.ts",
language = "TypeScript",
leadInMs = 420,
accent = "#7C7CF0",
onComplete,
}: StreamingCodeBlockProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const total = lines.length;
// Reduced motion: the snippet is there on the first frame. The
// information is the code; the trickle is presentation.
const [revealed, setRevealed] = useState(0);
const shown = reduceMotion ? total : revealed;
const streaming = shown < total;
// One timer per line, scheduled from the current count: nothing drifts
// out of sync and unmounting mid-stream cleans itself up.
useEffect(() => {
if (reduceMotion || revealed >= total) return;
const timer = setTimeout(
() => setRevealed((count) => count + 1),
revealed === 0 ? leadInMs : cfg.cadenceMs
);
return () => clearTimeout(timer);
}, [revealed, total, reduceMotion, leadInMs, cfg.cadenceMs]);
useEffect(() => {
if (!streaming) onComplete?.();
// Fires once per completion, not on every re-render of the parent.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streaming]);
const bodyHeight = Math.max(1, shown) * LINE_HEIGHT + BODY_PADDING * 2;
const gutterWidth = String(total).length * 8 + 14;
return (
<div
style={{
width: 340,
borderRadius: 12,
border: `1px solid ${tone(13)}`,
background: tone(5),
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 11px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden fill="none">
<path
d="M9.2 1.8H4.3a1.5 1.5 0 0 0-1.5 1.5v9.4a1.5 1.5 0 0 0 1.5 1.5h7.4a1.5 1.5 0 0 0 1.5-1.5V5.7L9.2 1.8Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
opacity="0.5"
/>
<path
d="M9 2v3.6h3.9"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
opacity="0.5"
/>
</svg>
<span
style={{
fontSize: 12,
fontWeight: 600,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
}}
>
{filename}
</span>
<span
style={{
marginLeft: "auto",
padding: "1.5px 7px",
borderRadius: 5,
fontSize: 10.5,
fontWeight: 650,
color: accent,
background: tone(8),
border: `1px solid ${tone(12)}`,
}}
>
{language}
</span>
</div>
{/*
The panel grows downward and nothing above it moves, so a reader
already looking at line 2 stays looking at line 2. Height is the
one non-transform property animated here, and it is animated
because the motion genuinely is a size change.
*/}
<motion.div
initial={false}
animate={{ height: bodyHeight }}
transition={{
duration: reduceMotion ? 0 : cfg.growSeconds,
ease: [0.22, 0.61, 0.36, 1],
}}
style={{ overflow: "hidden" }}
>
<div
style={{
padding: `${BODY_PADDING}px 12px`,
fontSize: 12.5,
lineHeight: `${LINE_HEIGHT}px`,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
}}
>
{lines.slice(0, shown).map((line, index) => (
<motion.div
key={index}
initial={reduceMotion ? false : { opacity: 0, y: cfg.riseY }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
style={{ display: "flex", whiteSpace: "pre" }}
>
<span
aria-hidden
style={{
width: gutterWidth,
flex: "0 0 auto",
textAlign: "right",
paddingRight: 10,
boxSizing: "border-box",
opacity: 0.3,
userSelect: "none",
}}
>
{index + 1}
</span>
<span style={{ opacity: 0.9 }}>
{tokenize(line).map((token, tokenIndex) => (
<span
key={tokenIndex}
style={
token.kind === "keyword"
? { color: accent, fontWeight: 600 }
: token.kind === "text"
? { color: "#3FA98B" }
: undefined
}
>
{token.text}
</span>
))}
</span>
{/* The caret rides the newest line so the eye has somewhere
to rest between arrivals. */}
{streaming && index === shown - 1 && (
<motion.span
aria-hidden
animate={{ opacity: [1, 1, 0, 0, 1] }}
transition={{
duration: 1,
repeat: Infinity,
ease: "linear",
times: [0, 0.45, 0.5, 0.95, 1],
}}
style={{
display: "inline-block",
width: 6.5,
height: 13,
marginLeft: 2,
alignSelf: "center",
borderRadius: 1,
background: accent,
}}
/>
)}
</motion.div>
))}
</div>
</motion.div>
<div
role="status"
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "7px 12px",
borderTop: `1px solid ${tone(10)}`,
fontSize: 11,
opacity: 0.55,
}}
>
<motion.span
aria-hidden
animate={
streaming && !reduceMotion
? { opacity: [0.45, 1, 0.45] }
: { opacity: 1 }
}
transition={
streaming && !reduceMotion
? { duration: 1.4, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.2 }
}
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: streaming ? accent : "#2E9E6B",
}}
/>
{streaming ? `Generating line ${shown + 1} of ${total}` : "Generated"}
</div>
</div>
);
}About this pattern
The panel a model fills in while it writes code. Each line resolves in place with a hair of rise, and the block's height eases open by exactly one line as it lands — so everything above stays where the reader left it and the page never scrolls out from under them. A caret rides the newest line, the gutter numbers keep count, and the footer changes from generating to generated when the last line arrives. Nothing springs: a panel that overshoots its own height jolts every line already on screen.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Fenced code answers fill out downward while the reply stays anchored.
Related patterns
- Tool Call BadgeA badge announces the tool an agent is calling, turns while it runs, then settles into the result count.
- Progressive Image GenerationA generated picture resolves from blur to sharp across a few discrete refinement passes.
- Streaming Text RevealWords arrive one at a time behind a soft caret — the answer is being written, not loaded.