All patterns

Streaming Code Block

Generated code arrives line by line while the panel grows downward to fit it.

aifuturisticminimalautomatic · finite · intermediate · ~1.6s
Variant

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.

347 lines · react + motion only
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.

Code generation replySnippet returned by an assistantTerminal output arrivingConfig file being written

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Ask a follow-up
    AI assistant

    Fenced code answers fill out downward while the reply stays anchored.

Related patterns