Advanced

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:

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
}
Naming convention: Always use PascalCase for component names (e.g. 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

TypeMeaningExample usage
"string"Text valueLabels, titles, URLs
"number"Numeric valueCounts, sizes, indices
"boolean"True / false flagVisibility, disabled state
"any"Any primitive or objectDynamic data, conditions
"callable"A function / action handleronClick, onSubmit
"Node"A single child componentSlots: header, footer
"Node[]"Multiple child componentschildren, 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

OptionDefaultDescription
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.

HelperSignatureDescription
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
ParameterTypeDescription
tagkeyof HTMLElementTagNameMapHTML tag name ("div", "button", "span", etc.)
attrsRecord<string, string | number | boolean | null>Attributes to set. null/undefined/false = omitted. true = boolean attribute.
childrenArray<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:

FunctionSignatureDescription
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:

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: The 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;
  },
};
Disposer keys: Passing a key (like "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
Constraint: At most one prop per component can be marked 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 propPurpose
sxInline style overrides via the Aktion sx system
animateEntry/exit animations
idDOM id attribute
className / classAdditional CSS classes
styleInline CSS string
ariaARIA attributes object
dataData attributes object
tooltipTooltip on hover
hiddenConditional visibility
roleARIA 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;
}
Note: Universal props (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

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
Caution: Override carefully. Programs written against the built-in’s prop interface will break if your replacement has a different signature. Match the prop names and types of the original when possible.

Next