AI UI Renderer
A live preview panel that renders AI-generated HTML with streaming updates, error forwarding, and theme injection.
Problem
You have an AI code generation pipeline that streams HTML fragments. You want to display partial output as it arrives, update the preview live without flicker, and report errors — all without reinitialising the sandbox on every token.
Solution
ts
import { createSandbox } from '@vielzeug/sandbox';
interface PreviewOptions {
container: HTMLElement;
onError: (message: string) => void;
onReady: () => void;
}
function createPreview({ container, onError, onReady }: PreviewOptions) {
const sandbox = createSandbox(container, {
namedStyles: {
base: `
:root { box-sizing: border-box; }
*, *::before, *::after { box-sizing: inherit; }
body { margin: 0; font-family: system-ui, sans-serif; }
`,
},
});
sandbox.onMessage((msg) => {
if (msg.type === 'error') onError(msg.message);
});
// render() sets up the document once — scripts, styles, and listeners are initialized here.
// patch() streams incremental body updates without reinitialising the page.
let initialized = false;
return {
async initialize() {
if (initialized) return;
// Empty body — content arrives via patch()
await sandbox.render('');
initialized = true;
onReady();
},
patch(html: string) {
sandbox.patch(html);
},
setTheme(theme: 'light' | 'dark') {
sandbox.setState('theme', theme);
},
[Symbol.dispose]() {
sandbox.dispose();
},
};
}
// Usage — streaming AI output
using preview = createPreview({
container: document.getElementById('preview')!,
onError: (msg) => showError(msg),
onReady: () => hideSpinner(),
});
await preview.initialize();
// Stream tokens as they arrive from the LLM
let accumulated = '';
for await (const chunk of streamUI(userPrompt)) {
accumulated += chunk;
preview.patch(accumulated); // live update, no page reset
}
// Push theme without re-render
preview.setTheme('dark');How streaming works
initialize()callsrender('')once — this sets up the bridge,namedStyles, and any injected scripts.- Each
patch(html)call sendsdocument.body.innerHTML = htmlvia postMessage. The iframe never navigates — scripts keep running, styles stay applied. - The bridge's built-in
ResizeObserverfires automatically as content grows, so the container can auto-size without additional wiring.
Pitfalls
patch()requiresrender()first — the bridge must be initialized before patches are received. Callpatch()only after the Promise fromrender()resolves.patch()replaces the entire body — it'sinnerHTML, not an append. If you want to append incrementally, accumulate on the host side (as shown above) and send the full accumulated string each time.- Error strings are untrusted —
msg.messageandmsg.stackcome from AI-generated code. Display them in the UI, but do not evaluate or pass them toFunction()oreval(). render()still needed for structural resets — if the user submits a new prompt and you want a fresh page (clear all script state), callrender('')again before patching.