Usage.

Connect your stream. Keep the content in place.

TypeScriptES modulesNo runtime dependencies

Install

Install the package in your app:

Your app · terminal
npm install stream-morph

One package, separate imports. The website and its editor are not included in the package.

Quick start

Choose the entry that matches how you render your response.

Append plain-text deltas. Words keep their DOM identity as the answer grows and wraps.

stream-morph/text
import { createStreamMorph } from "stream-morph/text";

const host = document.querySelector<HTMLElement>("#answer")!;
const stream = createStreamMorph(host);

// textChunks is your provider's async iterable of strings.
for await (const chunk of textChunks) {
  stream.write(chunk);
}
stream.finish();

// On unmount: stream.destroy();
stream-morph/textReady

This example runs the selected package entry. Play to see it receive chunks; it never starts on hover.

Built-in renderers

/view imports no renderers. Register only the kinds your response uses.

RendererEvent fieldsOutput
textRenderer()delta: stringPlain text with word motion.
codeRenderer()delta: string, optional languageCode with preserved newlines. No syntax highlighter.
imageRenderer()status, src, alt, optional ratioReserved space; an image fades in after loading.

Every event needs id and kind. Reuse the ID to update a block; keep its kind unchanged. Images accept loading, ready or error.

Markdown, reasoning, charts and tool cards

These are renderers supplied by your app. The showcase and playground include a separate website renderer for their Markdown and assistant examples. Those features are not bundled in the npm package.

Custom blocks

Create a block once, then update that same element as events arrive. Your renderer owns its content, styles and event handlers.

A tool result · stream-morph/view
import { createStreamView, type StreamRenderer } from "stream-morph/view";

const toolRenderer: StreamRenderer = (_id, document) => {
  const element = document.createElement("p");
  return {
    element,
    update(event) {
      element.textContent = String(event.label ?? "");
    },
    // Optional: flush() once per frame; destroy() on teardown.
  };
};

const view = createStreamView(container, {
  renderers: { tool: toolRenderer },
});
view.push({ id: "search", kind: "tool", label: "Searching…" });
view.push({ id: "search", kind: "tool", label: "Found three results" });

Return a detached HTML element from the supplied document. Use textContent for plain text; sanitize any HTML in your renderer. Optional flush() runs after the batch and destroy() releases resources.

Lifecycle

Batch updates
write() and push() queue work for the next animation frame. Call flush() when you need the DOM updated immediately.
Complete a stream
Text has finish(): it flushes the last chunk and prevents more writes. A view stays open for further events until destroyed.
Release resources
Call destroy() on unmount. Text becomes a plain text node; views and layout leave their rendered DOM in place. Cancel your provider separately.
Keep hosts stable
A view needs an empty host. Text owns a plain-text host. Layout animates elements with unique data-stream-morph-key values inside synchronous updates.

Motion options

OptionDefaultApplies to
duration180 msText, layout, view movement
enterDuration120 msText, layout, view entrance
maxAnimatedElements160Text and layout
maxAnimatedBlocks48View
localeBrowser defaultText word segmentation

Motion respects prefers-reduced-motion. Measurement limits bound animation work, not document size: long streams still accumulate nodes.

Browser APIs required: Web Animations, requestAnimationFrame and, for text, Intl.Segmenter. Importing the modules is safe on the server; create instances after mounting in the browser.

Try text, Markdown and your own CSS.

Open playground