Guide 6 of 9

Write an extension

Custom components: a catalog schema plus a painter per platform, or a macro only.

The catalog

An extension catalog has its own id, extends the core and adds components. It may never shadow a 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
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 painter per platform

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
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-at/ui"
import type { ExtensionDef } from "@exponential-at/ui"
import { defineReactExtension } from "@exponential-at/ui-react"
import type { ExtensionComponentProps } from "@exponential-at/ui-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 } })
PlatformRegister
ReactdefineReactExtension({ catalog, components }) · registerExtension(…)
SwiftUIExponentialUI.register(extension: json, painters: [kind: painter])
ComposeExponentialUi.register(extensionJson, mapOf(kind to painter))
gpuiview.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.

Teach the model

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, run rows and chips are an extension built exactly this way.

Compiled in CI

Every file on this page is a real file in apps/ui-site/guides/react. CI copies it into an empty directory and builds it from scratch, the way you would, so the example cannot drift from the SDK:

shell
bun apps/ui-site/guides/check.ts react

For a complete app on this platform, see the samples: four hosts rendering the same surface from a local A2UI server, in a third-party theme, with a custom extension component.

Next guideWrite a host plugin