Drop-in setup for every framework.
<streaming-ui-script> is a standard
Custom Element,
so it works in every framework that renders HTML — React, Next.js,
Vue, Nuxt, Angular, Svelte, Solid, Astro, Remix, and plain HTML.
Universal pattern
Three things matter no matter which framework you use:
- Import the bundle once in client-side code so it registers the custom element.
- Render
<streaming-ui-script>wherever you want the LLM output to appear. - Get a reference to the element and call
setResponse(),appendChunk(),setTheme(), orsetTools().
Pick your stack
Installation
Pick whichever channel suits your project:
# From npm — works for any bundler
npm install streaming-ui-script
# Or load from CDN — no build step required
<script type="module" src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js"></script>
The bundle is self-contained: it includes the parser, runtime, the complete component library, and the styles (rendered into the shadow DOM). You only ever need a single import.
React
React (< 19) doesn't natively understand custom-element props or
events, but it does pass attributes through and accepts a
ref. The pattern below uses a ref to call methods and a
useEffect hook to wire events.
// LLMResponse.tsx
import { useEffect, useRef } from "react";
import "streaming-ui-script";
interface Props {
response: string;
theme?: "light" | "dark" | "neon" | "pastel" | "glass" | "brutalist";
onAssistantMessage?: (text: string) => void;
}
export function LLMResponse({ response, theme = "light", onAssistantMessage }: Props) {
const ref = useRef<HTMLElement & { setResponse: (t: string) => void }>(null);
useEffect(() => {
ref.current?.setResponse(response);
}, [response]);
useEffect(() => {
const el = ref.current;
if (!el || !onAssistantMessage) return;
const handler = (e: Event) => onAssistantMessage((e as CustomEvent).detail.message);
el.addEventListener("assistant-message", handler);
return () => el.removeEventListener("assistant-message", handler);
}, [onAssistantMessage]);
return <streaming-ui-script ref={ref} theme={theme} transparent />;
}
Add the JSX intrinsic element type once so TypeScript stops complaining:
// types/streaming-ui-script.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from "react";
declare global {
namespace JSX {
interface IntrinsicElements {
"streaming-ui-script": DetailedHTMLProps<
HTMLAttributes<HTMLElement> & {
theme?: string;
transparent?: boolean;
showerrors?: string;
},
HTMLElement
>;
}
}
}
For React 19+ you can pass props directly as element properties — no ref needed for the simple read-only case:
<streaming-ui-script theme="dark" response={text} />
Next.js (app router)
Custom elements only work in the browser, so the wrapper must be a
client component (the import statement registers the element on
HTMLElement globals). Mark the file with
"use client" at the top.
// app/components/LLMResponse.tsx
"use client";
import { useEffect, useRef } from "react";
import "streaming-ui-script";
export function LLMResponse({ response, theme = "light" }: { response: string; theme?: string }) {
const ref = useRef<any>(null);
useEffect(() => { ref.current?.setResponse(response); }, [response]);
return <streaming-ui-script ref={ref} theme={theme} transparent />;
}
// app/page.tsx — server component
import { LLMResponse } from "./components/LLMResponse";
export default function Page() {
const program = `root = Stack([greeting])
greeting = Card([CardHeader("Hello", "Generative UI in Next.js")])`;
return <LLMResponse response={program} theme="light" />;
}
For SSR-only pages you can skip the wrapper and import via a
next/dynamic call with ssr: false:
const LLMResponse = dynamic(() => import("./components/LLMResponse").then(m => m.LLMResponse), {
ssr: false,
});
Vue 3
Tell Vue to skip its component resolver for any tag matching the custom
element. In vite.config.ts (or wherever the Vue plugin is
configured):
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: { compilerOptions: { isCustomElement: (tag) => tag === "streaming-ui-script" } },
}),
],
});
Then use it like any other element:
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import "streaming-ui-script";
const target = ref<HTMLElement | null>(null);
const response = ref(`root = Stack([Card([CardHeader("Hello from Vue")])])`);
const theme = ref("light");
onMounted(() => (target.value as any)?.setResponse(response.value));
watch(response, (next) => (target.value as any)?.setResponse(next));
</script>
<template>
<streaming-ui-script ref="target" :theme="theme" transparent />
</template>
Nuxt 3
Nuxt renders on the server by default, so the import has to be deferred
until the client. Use a <ClientOnly> wrapper and
register the element type in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
vue: {
compilerOptions: { isCustomElement: (tag) => tag === "streaming-ui-script" },
},
});
// plugins/streaming-ui-script.client.ts
import "streaming-ui-script";
export default defineNuxtPlugin(() => {});
<!-- pages/index.vue -->
<template>
<ClientOnly>
<streaming-ui-script theme="light" transparent ref="renderer" />
</ClientOnly>
</template>
Angular
Add CUSTOM_ELEMENTS_SCHEMA to the component (or module) so
the template compiler accepts the unknown tag, then import the bundle
once in main.ts:
// main.ts
import "streaming-ui-script";
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
bootstrapApplication(AppComponent);
// app.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, ViewChild, AfterViewInit } from "@angular/core";
@Component({
standalone: true,
selector: "app-root",
template: `<streaming-ui-script #renderer theme="light" transparent></streaming-ui-script>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppComponent implements AfterViewInit {
@ViewChild("renderer") renderer!: ElementRef<HTMLElement & { setResponse(t: string): void }>;
ngAfterViewInit() {
this.renderer.nativeElement.setResponse(`root = Stack([Card([CardHeader("Hello, Angular")])])`);
}
}
Svelte / SvelteKit
Svelte already understands custom elements. Import the bundle (in a
client-side +page.ts hook or directly in the component)
and you're done.
<!-- routes/+page.svelte -->
<script lang="ts">
import { onMount } from "svelte";
import "streaming-ui-script";
let renderer: any;
const program = `root = Stack([Card([CardHeader("Hello from Svelte")])])`;
onMount(() => renderer?.setResponse(program));
</script>
<streaming-ui-script bind:this={renderer} theme="light" transparent />
For server-rendered routes wrap the import in
browser-guarded code (e.g.
if (browser) await import("streaming-ui-script");) so it
only runs in the client bundle.
SolidJS
// App.tsx
import { onMount } from "solid-js";
import "streaming-ui-script";
export default function App() {
let renderer: any;
onMount(() => renderer?.setResponse(`root = Stack([Card([CardHeader("Hello, Solid")])])`));
return <streaming-ui-script ref={renderer} theme="light" transparent />;
}
Astro
Astro can ship the element either as a hydrated island or as a static tag with a small inline script. Both approaches are valid:
---
// src/pages/index.astro
---
<streaming-ui-script id="response" theme="light" transparent></streaming-ui-script>
<script>
import "streaming-ui-script";
const el = document.getElementById("response") as any;
el.setResponse(`root = Stack([Card([CardHeader("Hello, Astro")])])`);
</script>
Plain HTML
The CDN build is the smallest possible footprint — no bundler, no framework, just one script tag.
<!doctype html>
<html>
<body>
<streaming-ui-script theme="light" transparent></streaming-ui-script>
<script type="module">
import "https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js";
const el = document.querySelector("streaming-ui-script");
el.setResponse(`root = Stack([Card([CardHeader("Hello, world")])])`);
</script>
</body>
</html>
Common patterns across frameworks
Streaming an LLM response
Set streaming to true, push tokens with
appendChunk(), then flip the attribute back when the
stream ends. Internally the renderer suppresses transient parse errors
while the attribute is set.
el.streaming = true;
el.clear();
for await (const chunk of stream) el.appendChunk(chunk);
el.streaming = false;
Wiring tools (Query / Mutation)
Tools are async functions with a single argument object. The runtime
invokes them whenever a Query or Mutation
references them.
el.setTools({
list_orders: async ({ limit }) => (await fetch(`/api/orders?limit=${limit}`)).json(),
update_order: async ({ id, status }) => {
await fetch(`/api/orders/${id}`, { method: "PATCH", body: JSON.stringify({ status }) });
return { ok: true };
},
});
Reacting to follow-up clicks
Buttons that call @ToAssistant("...") dispatch an
assistant-message event with event.detail.message.
Hook it into your chat input.
el.addEventListener("assistant-message", (event) => {
sendToLLM(event.detail.message);
});
Aligning the background with your page
When the surrounding page already has a custom theme, pass the
transparent attribute (used in every snippet above) so the
host inherits the parent's background. The internal cards retain their
own surface colors. See the
theming page
for two more options.
Generating the system prompt
Either fetch the bundled file or build it programmatically — both return identical text:
// 1. Fetch the canned prompt
const promptText = await fetch("https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt.txt").then(r => r.text());
// 2. Generate at runtime (lets you add tools or extra rules)
import { SYSTEM_PROMPT_TEXT, generatePrompt, defaultLibrary } from "streaming-ui-script";
const same = SYSTEM_PROMPT_TEXT;
const custom = generatePrompt(defaultLibrary, {
preamble: "You are an analytics assistant.",
additionalRules: ["Always end with a FollowUpBlock."],
});
Need a different framework?
The element relies only on the Custom Elements v1 and ES2020 features. Anywhere you can render HTML and run JavaScript in a modern browser, the bundle just works.