laminar-ui
v0.1.2
Published
A React library for rendering UIs from streaming LLM / backend responses
Maintainers
Readme
Laminar UI
Render streaming LLM output in React.
LLMs stream text as a live feed of raw chunks. Mid-stream that output is broken containing unclosed code fences, half-finished tables, dangling ** and "_" tokens. Laminar handles incomplete markdown and JSON streams correctly, so your UI never breaks while the response is still arriving.
It handles the full output layer: parsing the SSE stream, healing torn markdown on every frame, and rendering it cleanly while tokens are still arriving.
npm install laminar-uiWhat it does
- Parses SSE streams from any LLM provider out of the box
- Patches incomplete markdown syntax in real time so rendering never breaks mid-stream
- Streams and progressively renders partial JSON for structured LLM outputs
- Works with any backend: raw fetch, Vercel AI SDK, Express, or your own setup
- Fully themeable via CSS variables
- Syntax highlighting in code blocks via Shiki, lazy-loaded so it doesn't impact your initial bundle
StreamMarkdown
A drop in component for streaming output in Markdown. Give it a fetcher that returns a streaming Response and it handles everything else.
import { StreamMarkdown } from "laminar-ui";
<StreamMarkdown
fetcher={() =>
fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ message: input }),
stream: true, // important to make the response streaming as laminar only handles streaming responses.
})
}
onFinish={() => console.log("Stream complete")}
/>;What it handles mid-stream
Laminar patches broken syntax on every render frame before passing it to the parser, so the output always looks correct even when the LLM is mid-sentence.
| Scenario | Example mid-stream | What Laminar does |
| -------------------- | ------------------------- | --------------------------------------------- |
| Unclosed code fence | ```python\nprint("hel | Auto-closes the block |
| Incomplete table row | \| Name \| Age | Adds closing pipe + newline |
| Dangling tokens | Some **bold | Strips for this frame, restores when complete |
Custom components
Override any rendered element with your own:
<StreamMarkdown
fetcher={fetcher}
components={{
h1: ({ children }) => <h1 className="my-heading">{children}</h1>,
code: ({ inline, lang, children }) =>
inline ? (
<code className="inline">{children}</code>
) : (
<MyCodeBlock lang={lang}>{children}</MyCodeBlock>
),
a: ({ href, children }) => (
<a href={href} target="_blank" className="my-link">
{children}
</a>
),
}}
/>Available overrides: h1 h2 h3 p a code table
StreamText
A lightweight alternative to StreamMarkdown for plain text output. Use this
when the LLM response doesn't contain markdown, only simple prose, single-line
answers, or any output where markdown rendering adds unnecessary overhead.
import { StreamText } from "laminar-ui";
<StreamText
fetcher={() =>
fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ message: input }),
stream: true,
})
}
onFinish={() => console.log("Stream complete")}
/>;Renders the raw text stream as-is with no markdown parsing or syntax
highlighting. If you need markdown, use StreamMarkdown instead.
useStream
The hook behind StreamMarkdown. Use this when you need direct control when building a custom renderer, triggering streams imperatively, or wiring into your own component tree.
import { useStream } from "laminar-ui";
function Chat() {
const { text, status, error, start, stop } = useStream();
return (
<>
<button
onClick={() => start(() => fetch("/api/chat", { method: "POST" }))}
>
Send
</button>
<button onClick={stop} disabled={status !== "streaming"}>
Stop
</button>
<pre>{text}</pre>
</>
);
}status moves through: "idle" → "streaming" → "done" (or "error" / "cancelled")
useStreamingJSON
When your LLM returns structured JSON instead of prose. You get a live, partially-hydrated JavaScript object on every frame and fields appear as they arrive, before the stream is complete.
import { useStreamingJSON } from "laminar-ui";
type Recipe = {
title: string;
ingredients: string[];
steps: string[];
};
const { data, status, start } = useStreamingJSON<Recipe>();
// Start streaming
start(() => fetch("/api/generate-recipe", { method: "POST" }));
// Render progressively; title appears before ingredients are done
<h1>{data?.title}</h1>
<ul>
{data?.ingredients?.map((item, i) => <li key={i}>{item}</li>)}
</ul>Uses partial-json internally to safely parse incomplete JSON on every frame without throwing.
createStreamAdapter
The SSE parser at Laminar's core. Reads a streaming Response, parses data: lines, and yields typed tokens. This function is framework-agnostic and works in any JavaScript environment.
import { createStreamAdapter } from "laminar-ui/core";
const response = await fetch("/api/chat", { method: "POST" });
const adapter = createStreamAdapter(response);
for await (const token of adapter) {
if (token.type === "text") process(token.content);
if (token.type === "done") finalize();
if (token.type === "error") handleError(token.content);
}Provider formats
Laminar defaults to OpenAI's SSE format. Pass a custom extractText function for other providers:
// OpenAI (default)
createStreamAdapter(response);
// Anthropic
createStreamAdapter(response, (json) => json.delta?.text);
// Gemini
createStreamAdapter(
response,
(json) => json.candidates?.[0]?.content?.parts?.[0]?.text,
);
// Your own backend
createStreamAdapter(response, (json) => json.output);The same extractText option is available on StreamMarkdown, useStream, and useStreamingJSON.
Integrations
With Vercel AI SDK
Already using useChat for message state? Laminar just replaces the rendering part:
import { useChat } from "ai/react";
import { MarkdownRenderer } from "laminar-ui";
function Chat() {
const { messages, input, handleSubmit, handleInputChange } = useChat();
return (
<>
{messages.map((msg) => (
<div key={msg.id}>
{msg.role === "assistant" ? (
<MarkdownRenderer text={msg.content} />
) : (
<p>{msg.content}</p>
)}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</>
);
}With a custom backend
No SDK needed. Point fetcher at your own endpoint:
<StreamMarkdown
fetcher={() =>
fetch("https://your-api.com/chat", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ prompt: userMessage }),
})
}
extractText={(json) => json.output} // match your API's shape
/>Directly with OpenAI / Anthropic
// OpenAI
<StreamMarkdown
fetcher={() =>
fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o",
stream: true,
messages: [{ role: "user", content: prompt }],
}),
})
}
/>
// Anthropic
<StreamMarkdown
fetcher={() =>
fetch("https://api.anthropic.com/v1/messages", { ... })
}
extractText={(json) => json.delta?.text}
/>Theming
All visual styling is controlled via CSS variables. Override them globally to match your design system.
:root {
--lm-heading: #111827;
--lm-link: #2563eb;
--lm-border: #e5e7eb;
--lm-inline-code-bg: #f3f4f6;
--lm-blockquote-border: #d1d5db;
--lm-blockquote-text: #6b7280;
--lm-del: #ef4444;
}
/* Dark mode */
[data-theme="dark"] {
--lm-heading: #f9fafb;
--lm-link: #60a5fa;
--lm-border: #374151;
--lm-inline-code-bg: #1f2937;
--lm-blockquote-border: #4b5563;
--lm-blockquote-text: #9ca3af;
--lm-del: #f87171;
}Import the base stylesheet once at your app root:
import "laminar-ui/laminar.css";API Reference
<StreamMarkdown />
| Prop | Type | Required | Description |
| ------------- | ------------------------- | -------- | ------------------------------------------------- |
| fetcher | () => Promise<Response> | ✓ | Function that initiates the LLM stream |
| onFinish | () => void | | Called when stream completes |
| extractText | (json: any) => string | | Custom token extractor. Defaults to OpenAI format |
| components | ComponentsMap | | Override rendered elements |
<StreamText />
| Prop | Type | Required | Description |
| ------------- | ------------------------- | -------- | ------------------------------------------------- |
| fetcher | () => Promise<Response> | ✓ | Function that initiates the LLM stream |
| onFinish | () => void | | Called when stream completes |
| extractText | (json: any) => string | | Custom token extractor. Defaults to OpenAI format |
useStream()
| | Type | Description |
| -------- | --------------------------------- | ------------------------------------------------------------------- |
| text | string | Full accumulated text so far |
| status | StreamStatus | "idle" | "streaming" | "done" | "error" | "cancelled" |
| error | string \| null | Error message if status is "error" |
| start | (fetcher, extractText?) => void | Begin streaming |
| stop | () => void | Cancel the stream |
useStreamingJSON<T>()
| | Type | Description |
| -------- | ---------------------------------- | ---------------------------------------- |
| data | T \| null | Live partial object, updated every frame |
| status | StreamStatus | Same as useStream |
| error | string \| null | Error message if status is "error" |
| start | (response, extractText?) => void | Begin streaming |
| stop | () => void | Cancel the stream |
Contributing
Issues and PRs are welcome.
git clone https://github.com/Yashkanekar/laminar-ui
cd laminar-ui
npm install
npm run devLicense
MIT
