Skip to content

Write an extension

An extension catalog has its own id, extends the core and adds components. Its names must differ from every core name. Each component has a model-facing description and props, like the core catalog. This one adds a native TrendLine and a macro Metric:

guides/react/src/trendline.extension.json
{
"id": "https://example.com/catalogs/metrics/v1",
"name": "Metrics",
"extends": "https://ui.exponential.at/catalogs/core/v1",
"components": {
"TrendLine": {
"kind": "native",
"group": "data",
"lite": true,
"children": "none",
"description": "A tiny line chart of recent values.",
"props": {
"values": { "type": "array", "items": { "type": "number" }, "required": true, "bindable": true, "description": "The values, oldest first." },
"height": { "type": "number", "default": 48, "description": "The chart height in points." }
},
"example": { "values": [3, 5, 4, 8, 6, 9] }
},
"Metric": {
"kind": "macro",
"group": "data",
"lite": true,
"children": "none",
"description": "A card with a label, a big value and, when given, a trend line of its history.",
"props": {
"label": { "type": "string", "required": true, "description": "What the number is." },
"value": { "type": "string", "required": true, "bindable": true, "description": "The formatted number." },
"history": { "type": "array", "items": { "type": "number" }, "description": "Recent values for the trend line." }
},
"example": { "label": "Open issues", "value": "42", "history": [30, 34, 39, 42] }
}
},
"macros": {
"Metric": {
"recipeProps": [],
"root": {
"part": "root",
"component": "Card",
"props": { "padded": true },
"children": [
{ "part": "label", "component": "Text", "props": { "text": "{props.label}", "variant": "muted" } },
{ "part": "value", "component": "Text", "props": { "text": "{props.value}", "variant": "title" } },
{ "part": "spark", "component": "TrendLine", "$if": "props.history", "props": { "values": "{props.history}" } }
]
}
}
}
}

A macro expands into existing components in the reducer, on every platform, so Metric needs no painter anywhere. If your component can be built from core components, a macro is all you need. Every part of a macro stays themable through recipes.

A native needs a painter on each platform you ship. On React, a component receives the resolved props, the theme and the mode, and spreads rootProps on its root element:

guides/react/src/TrendLine.tsx
// 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 { defineReactExtension } from "@exponential_app/eui-react"
import type { ExtensionComponentProps } from "@exponential_app/eui-react"
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)
function TrendLine({ props, rootProps, theme, mode }: ExtensionComponentProps) {
const values = Array.isArray(props.values) ? props.values.map(Number) : []
const height = Number(props.height ?? 48)
const min = Math.min(...values), max = Math.max(...values)
const y = (v: number) => (max === min ? 50 : 100 - ((v - min) / (max - min)) * 100)
const points = values.map((v, i) => `${(i / Math.max(values.length - 1, 1)) * 100},${y(v)}`).join(` `)
return (
<div {...rootProps} style={{ height, width: `100%` }}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height={height} role="img" aria-label={`${values.length} values`}>
<polyline points={points} fill="none" stroke={theme.modes[mode].color.primary} strokeWidth="2" vectorEffect="non-scaling-stroke" />
</svg>
</div>
)
}
// Per surface: <HostSurface … extensions={[metrics]} /> and new ExponentialHost({extensions: [metricsCatalog]})
// (or registerExtension({catalog, components}) once for every surface on the page).
export const metrics = defineReactExtension({ catalog: metricsCatalog, components: { TrendLine } })
Platform Register
React defineReactExtension({ catalog, components }) · registerExtension(…)
Solid defineSolidExtension({ catalog, components }) · registerExtension(…)
Web component defineElementExtension({ catalog, components }) · el.extensions = […]
SwiftUI ExponentialUI.register(extension: json, painters: [kind: painter])
Compose ExponentialUi.register(extensionJson, mapOf(kind to painter))
gpui view.register_painter("TrendLine", Box::new(MyTrendLine))

Native painters answer measure (the box the layout uses) and paint (the content inside the frame the core gives them). A painter for a core component name overrides that component, as long as it keeps the measure contract.

Pass the extension to the prompt (catalogPrompt({ extensions: [metricsCatalog] })) and to the host (new ExponentialHost({ extensions: [metricsCatalog] })), so the client advertises its id and the model knows the new components. The Exponential app’s issue rows, session rows and chips are an extension built exactly this way.

bun packages/exponential-ui/guides/check.ts react builds guides/react and reduces this extension and its macro against the packed package.