Usage.
Connect your stream. Keep the content in place.
Install
Install the package in your app:
npm install stream-morphOne 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.
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();Give each block a stable ID. Register the renderers you need, then push incoming events.
import { createStreamView } from "stream-morph/view";
import { textRenderer } from "stream-morph/renderers/text";
import { codeRenderer } from "stream-morph/renderers/code";
import { imageRenderer } from "stream-morph/renderers/image";
const host = document.querySelector<HTMLElement>("#answer")!;
const view = createStreamView(host, {
renderers: {
text: textRenderer(),
code: codeRenderer(),
image: imageRenderer(),
},
});
view.push({ id: "intro", kind: "text", delta: "Start here." });
view.push({ id: "example", kind: "code", delta: "const n = 1;\n" });
view.push({ id: "art", kind: "image", status: "loading", ratio: 16 / 9 });
view.push({
id: "art", kind: "image", status: "ready",
src: "/images/coast.svg", alt: "A coastal horizon",
});
// On unmount: view.destroy();Keep your Markdown or app renderer. Animate its synchronous DOM updates with stable element keys.
import { createStreamLayoutMorph } from "stream-morph/layout";
const host = document.querySelector<HTMLElement>("#answer")!;
const morph = createStreamLayoutMorph(host);
const answer = document.createElement("p");
answer.dataset.streamMorphKey = "answer";
morph.update(() => host.append(answer));
// Call this as content arrives from your renderer.
function appendChunk(chunk: string) {
morph.update(() => answer.append(chunk));
}
// On unmount: morph.destroy();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.
| Renderer | Event fields | Output |
|---|---|---|
textRenderer() | delta: string | Plain text with word motion. |
codeRenderer() | delta: string, optional language | Code with preserved newlines. No syntax highlighter. |
imageRenderer() | status, src, alt, optional ratio | Reserved 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.
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()andpush()queue work for the next animation frame. Callflush()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-keyvalues inside synchronous updates.
Motion options
| Option | Default | Applies to |
|---|---|---|
duration | 180 ms | Text, layout, view movement |
enterDuration | 120 ms | Text, layout, view entrance |
maxAnimatedElements | 160 | Text and layout |
maxAnimatedBlocks | 48 | View |
locale | Browser default | Text 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