Skip to content

Render A2UI as a web component

Two packages: @exponential_app/eui (the catalog, themes, reducer and host runtime) and @exponential_app/eui-element (the custom element <exponential-surface>). The page needs no framework and Vite needs no framework plugin; the element brings the Solid painter and its own styles.

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

The guide project’s whole dependency list and its Vite config:

guides/element/package.json
{
"name": "exponential-ui-guide-element",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@exponential_app/eui": "^0.12.0",
"@exponential_app/eui-element": "^0.12.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^7.1.0"
}
}
guides/element/vite.config.ts
import { defineConfig } from "vite"
// No framework plugin: the element is plain JavaScript.
export default defineConfig({})

Without a bundler, @exponential_app/eui-element/standalone is one self-contained ES module:

<script type="module" src="/vendor/exponential-ui-element/standalone.js"></script>

Importing @exponential_app/eui-element/define registers the element. Its attributes are the surface’s options (theme, mode, density, locale, direction, width, surface-id); the host box is display: contents, so the surface root is the box.

guides/element/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Exponential UI as a web component</title>
</head>
<body>
<exponential-surface theme="orange" mode="dark" surface-id="main"></exponential-surface>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

apply(message) takes A2UI v0.9 server messages; the element keeps the tree and the data model.

guides/element/src/main.ts
// Registers <exponential-surface>: no framework, the element paints itself.
import "@exponential_app/eui-element/define"
import { CORE_CATALOG_ID } from "@exponential_app/eui"
import type { ExponentialSurfaceElement } from "@exponential_app/eui-element"
import { metrics } from "./TrendLine"
const el = document.querySelector<ExponentialSurfaceElement>(`exponential-surface`)!
el.extensions = [metrics]
// A2UI v0.9 server messages in; the element keeps the tree and the data.
el.apply({ createSurface: { surfaceId: `main`, catalogId: CORE_CATALOG_ID } })
el.apply({
updateComponents: {
surfaceId: `main`,
components: [
{ id: `root`, component: `Column`, children: [`title`, `body`] },
{ id: `title`, component: `Text`, text: `Hello from the agent`, variant: `title` },
{ id: `body`, component: `Text`, text: { path: `/status` } },
],
},
})
el.apply({ updateDataModel: { surfaceId: `main`, path: `/status`, value: `Waiting for the agent…` } })
// Messages from the agent, here one JSON message per line over SSE.
const events = new EventSource(`/a2ui`)
events.onmessage = (e) => el.apply(JSON.parse(e.data))
// Actions go to your agent; waitUntil keeps the control pending until it answers.
el.addEventListener(`xui-action`, (e) => {
const { detail } = e as CustomEvent
detail.waitUntil(fetch(`/action`, { method: `POST`, body: JSON.stringify(detail) }))
})

Everything the user does arrives as a bubbling CustomEvent: xui-action, xui-input and xui-upload (detail.waitUntil(promise) keeps the control pending), xui-function-call (detail.respond(value)), xui-open-url (cancelable), xui-ready, xui-status, xui-paint-error. The DOM, the compiled stylesheet and the themes are the React renderer’s, checked case by case in headless Chromium (DOM, computed styles, frames and pixels).

src builds an ExponentialHost on the matching transport (transport = jsonl, sse or ws; ws: URLs pick WebSocket), connects when the element is attached and closes when it is removed; post is where actions go.

<exponential-surface src="/a2ui.jsonl" post="/action" surface-id="main" theme="orange"></exponential-surface>

Or hand it your own host, a theme JSON and a host plugin as properties:

el.host = new ExponentialHost({ transport: new JsonlStreamTransport({ url: "/a2ui.jsonl", postUrl: "/action" }) })
el.theme = brandJson
el.plugin = { icons: iconsFromSvg((name) => svgFor(name)), openUrl: (url) => window.open(url) }

Extensions use defineElementExtension: a painter is a plain function from a context (props, theme, mode, applyRootProps, children()) to an Element, run again when they change. More in the web component’s README.

guides/element/src/TrendLine.ts
// An extension = a catalog (JSON: what the model may use) + one painter per
// NATIVE component. `Metric` is a macro: it expands into Card + Text +
// TrendLine in the reducer and needs no painter on any platform.
import { defineExtension } from "@exponential_app/eui"
import type { ExtensionDef } from "@exponential_app/eui"
import { defineElementExtension } from "@exponential_app/eui-element"
import catalog from "./trendline.extension.json"
// JSON imports widen literals, hence the cast; defineExtension checks it and throws listing every problem.
export const metricsCatalog = defineExtension(catalog as unknown as ExtensionDef)
const SVG = `http://www.w3.org/2000/svg`
// A vanilla painter: returns an Element, re-run whenever props, theme or mode change.
export const metrics = defineElementExtension({
catalog: metricsCatalog,
components: {
TrendLine: (ctx) => {
const values = Array.isArray(ctx.props.values) ? ctx.props.values.map(Number) : []
const height = Number(ctx.props.height ?? 48)
const min = Math.min(...values), max = Math.max(...values)
const y = (n: number) => (max === min ? 50 : 100 - ((n - min) / (max - min)) * 100)
const root = document.createElement(`div`)
ctx.applyRootProps(root)
root.style.height = `${height}px`
root.style.width = `100%`
const svg = document.createElementNS(SVG, `svg`)
for (const [k, v] of Object.entries({ viewBox: `0 0 100 100`, preserveAspectRatio: `none`, width: `100%`, height: String(height), role: `img`, "aria-label": `${values.length} values` })) svg.setAttribute(k, v)
const line = document.createElementNS(SVG, `polyline`)
line.setAttribute(`points`, values.map((n, i) => `${(i / Math.max(values.length - 1, 1)) * 100},${y(n)}`).join(` `))
for (const [k, v] of Object.entries({ fill: `none`, stroke: ctx.theme.modes[ctx.mode].color.primary, "stroke-width": `2`, "vector-effect": `non-scaling-stroke` })) line.setAttribute(k, v)
svg.append(line)
root.append(svg)
return root
},
},
})
// Per surface: el.extensions = [metrics] and new ExponentialHost({extensions: [metricsCatalog]})
// (or registerExtension(metrics) once for every surface on the page).

defineSurface from @exponential_app/eui works unchanged in a plain Vite project: the typed tree becomes the same messages an agent sends, and apply() on the element takes them. The file compiles under this project’s tsconfig and runs under Node, with nothing element-specific in it. The DSL is the one from Write a surface in TypeScript; the second file paints it with <exponential-surface>.

guides/element/src/defined.ts
// The typed DSL from a plain Vite project: the same `defineSurface` as the
// TypeScript guide, compiled by this project's tsconfig with nothing changed.
// Every component's props autocomplete from the catalog; a wrong prop is a
// type error, a wrong value a validator issue. `node src/defined.ts` prints
// the JSONL a host consumes.
import { bind, call, defineSurface, event, run, ui } from "@exponential_app/eui"
const entries = ui.Stack({ gap: `md` }, [
ui.Section({ title: `Entries`, icon: `ui-clock`, count: 2 }),
ui.List(
{ divided: true },
{ template: ui.Row({ title: bind(`label`), subtitle: bind(`detail`), icon: `ui-clock`, chevron: `right`, pressable: true }, undefined, { on: { press: event(`openEntry`, { id: bind(`id`) }) } }), path: `/entries/rows`, key: `id` }
),
ui.EmptyState({ icon: `ui-clock`, title: `No hours yet`, description: `Log the hours you worked and they appear here.`, actionLabel: `Log hours` }, undefined, {
visible: call(`eq`, { a: 0, b: bind(`/entries/count`) }),
on: { press: run(`app.toast`, { title: `Soon` }) },
}),
])
export const defined = defineSurface(entries, {
surfaceId: `working-hours`,
data: { "/entries": { count: 2, rows: [{ id: `e1`, label: `support, admin, orgs`, detail: `2026-10-23 · 3 h` }, { id: `e2`, label: `review, onboarding`, detail: `2026-10-22 · 4.5 h` }] } },
})
if (defined.issues.length) throw new Error(defined.issues.map((i) => `${i.id}: ${i.message}`).join(`\n`))
console.log(defined.jsonl)
guides/element/src/mount-defined.ts
// A surface written in TypeScript, painted by <exponential-surface>: the
// messages defineSurface produced are the ones an agent would send.
import "@exponential_app/eui-element/define"
import type { ExponentialSurfaceElement } from "@exponential_app/eui-element"
import { defined } from "./defined"
export function mountDefined(parent: HTMLElement): ExponentialSurfaceElement {
const el = document.createElement(`exponential-surface`) as ExponentialSurfaceElement
el.setAttribute(`surface-id`, defined.surfaceId)
el.setAttribute(`theme`, `orange`)
el.setAttribute(`mode`, `dark`)
parent.append(el)
for (const message of defined.messages) el.apply(message as Parameters<ExponentialSurfaceElement[`apply`]>[0])
el.addEventListener(`xui-action`, (e) => console.log((e as CustomEvent).detail))
return el
}

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