Skip to content

Render A2UI in React

Two packages: @exponential_app/eui (the catalog, themes, reducer and host runtime) and @exponential_app/eui-react (the renderer). It needs React 19. The renderer ships its own styles, so a stylesheet or Tailwind setup is unnecessary.

Terminal window
npm install @exponential_app/eui @exponential_app/eui-react

The guide project’s whole dependency list:

guides/react/package.json
{
"name": "exponential-ui-guide-react",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@exponential_app/eui": "^0.12.0",
"@exponential_app/eui-react": "^0.12.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^5.7.2",
"vite": "^7.1.0"
}
}

One ExponentialHost per app: it reads A2UI messages from a transport, routes them to surfaces and posts the user’s actions back. <HostSurface> paints one surface by id, in a theme and a mode, with a fallback until the agent has created it.

guides/react/src/App.tsx
import { useEffect, useMemo } from "react"
import { ExponentialHost, JsonlStreamTransport } from "@exponential_app/eui"
import { HostSurface } from "@exponential_app/eui-react"
import { Button } from "@exponential_app/eui-react/primitives"
import "@exponential_app/eui-react/styles.css"
export function App() {
// One host per app: A2UI messages in over a transport, actions back out.
const host = useMemo(
() => new ExponentialHost({ transport: new JsonlStreamTransport({ url: `/a2ui.jsonl`, postUrl: `/action` }) }),
[]
)
useEffect(() => {
host.connect()
return () => host.close()
}, [host])
// Paints the surface the agent creates; the theme is data, the mode a prop.
// Below it, a primitive from the same set (the stylesheet above styles it).
return (
<>
<HostSurface host={host} surfaceId="main" theme="exponential" mode="dark" fallback={<p>Waiting for the agent…</p>} />
<Button variant="outline" onClick={() => { host.close(); host.connect() }}>
Reconnect
</Button>
</>
)
}

The transport here reads a JSONL stream and posts actions to /action. Swap in SseTransport, WebSocketTransport, McpTransport (A2UI inside MCP tool results) or MemoryTransport (you push the messages) and the surface stays as it is. theme takes a built-in id, a theme JSON or a resolved theme.

When you already have the messages (a chat transcript, a test), skip the host: useSurface reduces messages into a tree and <ExponentialSurface> paints it. The plugin object takes icons, onAction, onInput, openUrl and extra functions.

const surface = useSurface({ surfaceId: "main" })
useEffect(() => messages.forEach(surface.apply), [messages])
return <ExponentialSurface surface={surface} theme="neutral" mode="light" host={{ onAction }} />

The React renderer’s README covers how a theme becomes scoped CSS, overlays, windowed lists and painter overrides.

The app around a surface can use the same components: @exponential_app/eui-react/primitives is the shadcn set on Radix (button, dialog, select, table, command, toast, calendar and more). Import its compiled stylesheet once. Inside a surface they wear its theme; outside one, globalsCss(theme) from @exponential_app/eui/web writes the theme as shadcn variables.

import "@exponential_app/eui-react/styles.css"
import { builtinTheme } from "@exponential_app/eui"
import { globalsCss } from "@exponential_app/eui/web"
import { Button, Dialog, DialogContent, DialogTitle, DialogTrigger, Toaster, toast } from "@exponential_app/eui-react/primitives"
const globals = globalsCss(builtinTheme("orange")) // :root + .dark, put it in a <style>
<Dialog>
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
<DialogContent><DialogTitle>Saved views</DialogTitle></DialogContent>
</Dialog>
<Button variant="outline" onClick={() => toast.success("Saved")}>Save</Button>
<Toaster />

bun packages/exponential-ui/guides/check.ts react builds guides/react in an empty directory from the packed packages.