Custom Components.
Extend Aktion’s built-in library with your own components.
Define a ComponentSpec, register it with the host element,
and use it in your Aktion programs just like any built-in.
Overview
Every component in Aktion — built-in or custom — is a plain
JavaScript object that conforms to the ComponentSpec interface.
A spec declares:
- A name — how the component is called in Aktion source
- A description — used by LLM prompts and tooling
- A list of props — the arguments the component accepts
- A render function — returns a DOM
Nodegiven the resolved props
Once you register a custom component on the <aktion-app>
element, it becomes immediately available for use inside your programs.
The ComponentSpec interface
Here is the full shape of a component specification:
interface ComponentSpec {
name: string; // PascalCase component name
description: string; // One-line summary for tooling / prompts
props: PropSpec[]; // Ordered list of accepted props
render: ComponentRenderFn; // (node, props, helpers) => Node
}
MyWidget, PricingCard). This is how the
Aktion parser distinguishes component calls from other identifiers.
Defining props with PropSpec
Each entry in the props array describes one argument your
component accepts. The order matters — it determines how positional
arguments bind at the call site.
interface PropSpec {
name: string;
type: PrimitiveType | string;
optional?: boolean;
positional?: boolean;
required?: boolean;
description?: string;
enum?: readonly string[];
aliases?: readonly string[];
}
Prop types
| Type | Meaning | Example usage |
|---|---|---|
"string" | Text value | Labels, titles, URLs |
"number" | Numeric value | Counts, sizes, indices |
"boolean" | True / false flag | Visibility, disabled state |
"any" | Any primitive or object | Dynamic data, conditions |
"callable" | A function / action handler | onClick, onSubmit |
"Node" | A single child component | Slots: header, footer |
"Node[]" | Multiple child components | children, items |
You can also use custom type strings like "Col[]" or
"Series[]" when a prop accepts an array of a specific
component shape. These are documentation markers — the runtime
treats them the same as "any".
Prop options
| Option | Default | Description |
|---|---|---|
optional |
false |
If true, the prop can be omitted at the call site. |
positional |
false |
Marks this prop as the positional argument. At most one prop per component can be positional. When omitted, prop index 0 is treated as positional by convention. |
required |
false |
Schema marker for prompt generation. Not enforced at runtime. |
enum |
— | Array of accepted string values. Used by tooling for autocompletion and documented in generated prompts. |
aliases |
— |
Alternative names that route to this prop. E.g. a prop named
variant with aliases: ["tone"] allows
authors to write either name at the call site.
|
description |
— | Human-readable explanation. Shown in editor hover and system prompts. |
The render function
The render function is the heart of every component. It receives three arguments:
type ComponentRenderFn = (
node: ComponentNode, // The AST node (rarely needed)
props: Record<string, unknown>, // Resolved prop values
helpers: RenderHelpers // Utilities for rendering and state
) => Node;
Your render function must return a single DOM Node. This can be an
HTMLElement, a DocumentFragment (for wrapper-free output),
or a Text node.
RenderHelpers reference
The helpers object provides everything you need to compose children,
handle events, manage state, and interact with the router.
| Helper | Signature | Description |
|---|---|---|
renderNode |
(node: unknown) => Node |
Render a child component node (or array of nodes) to DOM. Use this for every Node / Node[] prop. |
invoke |
(callable: unknown, ...args) => void |
Safely invoke a user-supplied callback. No-ops if the value is null or undefined. Always use this instead of calling props directly. |
setState |
(name: string, value: unknown) => void |
Set a reactive $state variable by name. Triggers a re-render. |
resetState |
(...names: string[]) => void |
Reset one or more state variables to their initial declared values. |
bindState |
(el, name, options?) => void |
Two-way bind a $variable to an HTML form element. Supports custom events and value extractors. |
useInstanceState |
<T>(key, initial) => { get(), set(v) } |
Persist component-local state across re-renders. Each instance gets its own isolated slot. |
registerDisposer |
(cleanup: () => void, key?) => void |
Register a teardown callback for when the component is removed from the tree. Use for timers, observers, and subscriptions. |
sendToAssistant |
(message: string) => void |
Dispatch an assistant-message event on the host, useful for chat/LLM integrations. |
openUrl |
(url: string) => void |
Open a URL safely (sanitised against javascript: payloads). |
router |
Router |
The hash-based router instance. Call router.navigate(path) to change routes programmatically. |
The el() DOM factory
While not part of RenderHelpers, the el() function
is the standard way to create DOM elements inside render functions. Import it
from the library utilities:
import { el } from "aktion-runtime/library";
// Signature
el(tag, attrs?, children?) => HTMLElement
| Parameter | Type | Description |
|---|---|---|
tag | keyof HTMLElementTagNameMap | HTML tag name ("div", "button", "span", etc.) |
attrs | Record<string, string | number | boolean | null> | Attributes to set. null/undefined/false = omitted. true = boolean attribute. |
children | Array<Node | string | null> | Child nodes or text strings to append. |
// Example usage
const btn = el("button", {
class: "my-btn",
disabled: isLoading || null,
"data-variant": "primary",
}, ["Click me"]);
Utility functions
These coercion helpers normalize prop values from the Aktion runtime
(which are typed as unknown) into the types your render logic needs:
| Function | Signature | Description |
|---|---|---|
asString |
(value, fallback?) => string |
Coerce to string. null/undefined returns the fallback (default ""). |
asArray |
(value) => T[] |
Wraps scalars in an array. null/undefined returns []. Arrays pass through. |
asBoolean |
(value, fallback?) => boolean |
Coerce to boolean. Also handles "true"/"false" strings. |
asNumber |
(value, fallback?) => number |
Coerce to number from a number or numeric string. Returns fallback (default 0) on failure. |
classNames |
(...parts) => string |
Join class strings, filtering out falsy values. classNames("a", false, "b") → "a b". |
text |
(value) => string |
Display-safe text coercion. Returns "" for null/undefined. |
Registering custom components
After defining your ComponentSpec, register it on the
<aktion-app> element:
const app = document.querySelector("aktion-app");
// Register one or more components
app.registerComponents([MyButton, MyCard, MyChart]);
// Optionally set a custom root component
app.registerComponents([MyLayout], "MyLayout");
Key points:
- Call
registerComponents(specs[], rootName?)with an array of specs. - Custom components override built-ins on name collision — your
Buttonreplaces the built-inButton. - The optional
rootNamesets the default wrapper for top-level content (normally"Column"). - Registration triggers an immediate re-render, so existing programs pick up the new components instantly.
- You can call
registerComponentsmultiple times — each call merges into the library.
Example: Simple text component
Let’s start with the simplest possible component — a styled label:
import { el, asString } from "aktion-runtime/library";
const ColorLabel = {
name: "ColorLabel",
description: "A text label with a colored background.",
props: [
{ name: "text", type: "string" },
{ name: "color", type: "string", optional: true,
enum: ["red", "green", "blue", "orange"] },
],
render: (_node, props) => {
return el("span", {
class: "color-label",
style: `background: ${asString(props.color, "blue")}; color: white;
padding: 4px 12px; border-radius: 4px; font-weight: 600;`,
}, [asString(props.text, "Label")]);
},
};
Usage in Aktion (after registering):
$app(Column([
ColorLabel("Hello!", { color: "green" }),
ColorLabel("Warning", { color: "orange" }),
ColorLabel("Error", { color: "red" }),
], { gap: "sm" }))
Example: Component with event handling
Components use helpers.invoke() to safely call user-supplied
callbacks. Here’s a counter button:
import { el, asString, asNumber } from "aktion-runtime/library";
const CounterButton = {
name: "CounterButton",
description: "A button that displays a count and fires onClick when pressed.",
props: [
{ name: "label", type: "string" },
{ name: "count", type: "number", optional: true },
{ name: "onClick", type: "callable", optional: true },
{ name: "variant", type: "string", optional: true,
enum: ["primary", "secondary", "danger"] },
],
render: (_node, props, helpers) => {
const label = asString(props.label, "Click");
const count = asNumber(props.count, 0);
const variant = asString(props.variant, "primary");
const btn = el("button", {
class: `counter-btn counter-btn--${variant}`,
}, [`${label} (${count})`]);
btn.onclick = () => helpers.invoke(props.onClick);
return btn;
},
};
Usage in Aktion (after registering):
$count = 0
$app(CounterButton("Likes", { count: $count, onClick: () => {
$count = $count + 1
}}))
Here’s the equivalent pattern working live with the built-in
Button — the event-handling mechanism is the same:
$count = 0
$app(Button("Likes: " + $count, { variant: "primary", icon: "heart", onClick: () => {
$count = $count + 1
}}))
Example: Component with children (slots)
In Aktion, slots are just props with type "Node" or
"Node[]". Use helpers.renderNode() to turn them
into DOM:
import { el, asString, asArray } from "aktion-runtime/library";
const Panel = {
name: "Panel",
description: "A panel container with header, body, and optional footer slots.",
props: [
{ name: "title", type: "string" },
{ name: "children", type: "Node[]", aliases: ["body"] },
{ name: "footer", type: "Node", optional: true },
{ name: "variant", type: "string", optional: true,
enum: ["default", "outlined", "elevated"] },
],
render: (_node, props, helpers) => {
const root = el("div", {
class: `panel panel--${asString(props.variant, "default")}`,
});
// Header
const header = el("div", { class: "panel-header" },
[asString(props.title)]);
root.append(header);
// Body — render each child node
const body = el("div", { class: "panel-body" });
for (const child of asArray(props.children)) {
body.append(helpers.renderNode(child));
}
root.append(body);
// Optional footer slot
if (props.footer) {
const footer = el("div", { class: "panel-footer" });
footer.append(helpers.renderNode(props.footer));
root.append(footer);
}
return root;
},
};
Usage in Aktion (after registering):
$app(Panel("My Panel", { variant: "elevated", children: [
Text("This is the panel body content."),
Text("Multiple children are supported."),
], footer: Badge("v1.2.0") }))
For reference, here’s the same pattern achieved with the built-in
Card component — your custom Panel would
produce similar output:
$app(Card([
CardHeader("My Panel"),
Text("This is the panel body content."),
Text("Multiple children are supported."),
CardFooter([Badge("v1.2.0")])
], { variant: "elevated" }))
Example: Stateful component with useInstanceState
Some components need internal UI state that persists across re-renders
(e.g. an open/closed toggle, an active tab). Use
helpers.useInstanceState():
import { el, asString, asArray } from "aktion-runtime/library";
const Accordion = {
name: "Accordion",
description: "A collapsible section with a clickable header.",
props: [
{ name: "title", type: "string" },
{ name: "children", type: "Node[]" },
{ name: "defaultOpen", type: "boolean", optional: true },
],
render: (_node, props, helpers) => {
// Instance state survives re-renders — each Accordion has its own slot
const open = helpers.useInstanceState(
"open",
props.defaultOpen ?? false
);
const root = el("div", { class: "accordion" });
// Header (clickable)
const header = el("button", {
class: "accordion-header",
"aria-expanded": String(open.get()),
}, [
asString(props.title),
open.get() ? " ▾" : " ▸",
]);
header.onclick = () => open.set(!open.get());
root.append(header);
// Body (conditionally visible)
if (open.get()) {
const body = el("div", { class: "accordion-body" });
for (const child of asArray(props.children)) {
body.append(helpers.renderNode(child));
}
root.append(body);
}
return root;
},
};
key parameter ("open" above)
must be a stable string unique within the component. Multiple calls to
useInstanceState with different keys are fine — each gets
its own slot.
Example: Component with cleanup (registerDisposer)
If your component sets up timers, event listeners, or observers, use
helpers.registerDisposer() to clean them up when the component
leaves the tree:
import { el, asNumber } from "aktion-runtime/library";
const LiveClock = {
name: "LiveClock",
description: "Displays a live-updating clock.",
props: [
{ name: "interval", type: "number", optional: true,
description: "Update interval in milliseconds" },
],
render: (_node, props, helpers) => {
const intervalMs = asNumber(props.interval, 1000);
const timeSlot = helpers.useInstanceState("time", new Date().toLocaleTimeString());
const span = el("span", { class: "live-clock" }, [timeSlot.get()]);
const timer = setInterval(() => {
timeSlot.set(new Date().toLocaleTimeString());
}, intervalMs);
// Clean up when the component is removed from the tree
helpers.registerDisposer(() => clearInterval(timer), "clock-timer");
return span;
},
};
"clock-timer")
means calling registerDisposer again with the same key on a
re-render replaces the previous disposer rather than stacking them.
Real-world example: The Buttons component
Here’s how the built-in Buttons component is implemented.
It’s a great example of a layout component that composes children:
import { el, asString, asArray } from "aktion-runtime/library";
const Buttons = {
name: "Buttons",
description: "Group of buttons laid out horizontally or vertically.",
props: [
{ name: "items", type: "Button[]" },
{ name: "direction", type: "string", optional: true,
enum: ["row", "column"] },
],
render: (_node, props, helpers) => {
const root = el("div", {
class: "rui-buttons",
"data-direction": asString(props.direction, "row"),
});
for (const child of asArray(props.items)) {
root.append(helpers.renderNode(child));
}
return root;
},
};
Usage in Aktion:
$app(Buttons([
Button("Save", { variant: "primary", icon: "check" }),
Button("Cancel", { variant: "ghost" }),
Button("Delete", { variant: "danger", icon: "trash" }),
], { direction: "row" }))
Example: Form component with two-way binding
Use helpers.bindState() to create two-way bindings between
form elements and Aktion’s reactive $state variables:
import { el, asString, valueAttr } from "aktion-runtime/library";
const RangeSlider = {
name: "RangeSlider",
description: "A range input that binds to a $state variable.",
props: [
{ name: "bind", type: "string", required: true,
description: "Name of the $state variable to bind" },
{ name: "min", type: "number", optional: true },
{ name: "max", type: "number", optional: true },
{ name: "step", type: "number", optional: true },
{ name: "label", type: "string", optional: true },
],
render: (_node, props, helpers) => {
const root = el("div", { class: "range-slider" });
if (props.label) {
root.append(el("label", {}, [asString(props.label)]));
}
const input = el("input", {
type: "range",
min: valueAttr(props.min) ?? "0",
max: valueAttr(props.max) ?? "100",
step: valueAttr(props.step) ?? "1",
value: valueAttr(props[props.bind as string]),
});
// Two-way bind to the $state variable
helpers.bindState(input, asString(props.bind), {
event: "input",
getValue: (el) => Number((el as HTMLInputElement).value),
});
root.append(input);
return root;
},
};
Positional props
By default, the first prop in the array is treated as the positional argument. Authors can pass it without a name at the call site:
// With this spec...
props: [
{ name: "label", type: "string" }, // positional (index 0)
{ name: "variant", type: "string", optional: true },
]
// ...the call site can use:
MyButton("Click me", { variant: "primary" })
// or the explicit form:
MyButton({ label: "Click me", variant: "primary" })
To make a different prop positional (not index 0), mark it explicitly:
props: [
{ name: "config", type: "any", optional: true },
{ name: "children", type: "Node[]", positional: true },
]
// Now: MyWrapper(...) { content } routes children to index 1
positional: true. The runtime enforces this at
registration time.
Prop aliases
Aliases let a single prop accept multiple names at the call site. The runtime
resolves all aliases to the canonical props[spec.name] value:
props: [
{ name: "variant", type: "string", optional: true,
aliases: ["tone", "style"] },
]
// All of these are equivalent in Aktion:
// MyBadge({ variant: "success" })
// MyBadge({ tone: "success" })
// MyBadge({ style: "success" })
Universal props
Every component (built-in and custom) automatically accepts a set of universal props without you declaring them in the spec:
| Universal prop | Purpose |
|---|---|
sx | Inline style overrides via the Aktion sx system |
animate | Entry/exit animations |
id | DOM id attribute |
className / class | Additional CSS classes |
style | Inline CSS string |
aria | ARIA attributes object |
data | Data attributes object |
tooltip | Tooltip on hover |
hidden | Conditional visibility |
role | ARIA role override |
These are handled by the renderer after your render() returns.
You do not need to process them — they are applied to your root element automatically.
Returning a DocumentFragment
If your component should not produce a wrapper element, return a
DocumentFragment:
render: (_node, props, helpers) => {
const frag = document.createDocumentFragment();
for (const child of asArray(props.children)) {
frag.append(helpers.renderNode(child));
}
return frag;
}
sx, id, etc.)
cannot be applied to a DocumentFragment — they need a real
element. Use fragments only when you genuinely need wrapper-free output.
Full integration example
Here’s a complete, copy-paste-ready example that registers a custom
StarRating component and uses it in an Aktion program:
<aktion-app id="my-app"></aktion-app>
<script type="module">
import "aktion-runtime";
const StarRating = {
name: "StarRating",
description: "Interactive star rating with configurable max stars.",
props: [
{ name: "bind", type: "string", required: true,
description: "The $state variable to bind the rating to" },
{ name: "max", type: "number", optional: true },
{ name: "size", type: "string", optional: true,
enum: ["sm", "md", "lg"] },
{ name: "readonly", type: "boolean", optional: true },
],
render: (_node, props, helpers) => {
const max = Number(props.max) || 5;
const size = String(props.size || "md");
const readonly = Boolean(props.readonly);
const current = helpers.useInstanceState("value", 0);
const root = document.createElement("div");
root.className = `star-rating star-rating--${size}`;
for (let i = 1; i <= max; i++) {
const star = document.createElement("span");
star.textContent = i <= current.get() ? "★" : "☆";
star.className = i <= current.get() ? "star filled" : "star";
if (!readonly) {
star.style.cursor = "pointer";
star.onclick = () => {
current.set(i);
helpers.setState(String(props.bind), i);
};
}
root.append(star);
}
return root;
},
};
const app = document.getElementById("my-app");
app.registerComponents([StarRating]);
app.setResponse(`
$rating = 0
$app(Column([
Heading("Rate this product", { level: 3 }),
StarRating({ bind: "rating", max: 5, size: "lg" }),
Text("You rated: " + $rating + " stars"),
], { gap: "md" }))
`);
</script>
Best practices
-
Always use
helpers.invoke()for callbacks — never callprops.onClick()directly. The invoke helper handlesnull/undefinedsafely and dispatches through the Aktion action pipeline. -
Use coercion helpers (
asString,asBoolean, etc.) instead of raw casts. Props arrive asunknownand may be any type. -
Return a single root element. If you need multiple siblings,
wrap them in a
<div>or useDocumentFragment. -
Prefix CSS classes with a namespace (e.g.
my-) to avoid collisions with built-in styles (rui-prefix). - Register cleanup for any side effects — intervals, event listeners, mutation observers. The tree can re-render at any time.
-
Keep renders pure — avoid fetching data or performing
side effects inside the render function itself. Use
useInstanceStateto persist state, and let the Aktion program drive data loading via$http. -
Provide good
descriptionstrings — they power editor hover, autocompletion, and LLM system prompts.
TypeScript support
All types are exported from the aktion-runtime/library subpath:
import type {
ComponentSpec,
ComponentRenderFn,
PropSpec,
RenderHelpers,
InstanceStateSlot,
ComponentLibrary,
} from "aktion-runtime/library";
import { el, asString, asArray, asBoolean, asNumber } from "aktion-runtime/library";
With strict TypeScript, define your spec with a type annotation:
const MyComponent: ComponentSpec = {
name: "MyComponent",
description: "...",
props: [...],
render: (_node, props, helpers) => { ... },
};
Overriding built-in components
Registering a component with the same name as a built-in replaces it entirely. This is useful for customizing default behaviour:
// Override the built-in Button with your own design system
const Button = {
name: "Button", // same name as the built-in
description: "Custom button using our design system.",
props: [
{ name: "label", type: "string" },
{ name: "onClick", type: "callable", optional: true },
{ name: "variant", type: "string", optional: true,
enum: ["primary", "secondary", "ghost"] },
],
render: (_node, props, helpers) => {
const btn = el("button", {
class: `ds-button ds-button--${asString(props.variant, "primary")}`,
}, [asString(props.label)]);
btn.onclick = () => helpers.invoke(props.onClick);
return btn;
},
};
app.registerComponents([Button]);
// All Aktion programs using Button() now render YOUR implementation
Next
Components
Browse the full built-in component library with signatures and examples.
View the catalog → AdvancedThird-party widgets
Embed external JavaScript libraries and web components into your Aktion apps.
Read the guide → GuideTypeScript
Type-safe integration with the full Aktion public API surface.
View the guide →