Skip to content

Custom components

When the starter set does not cover what you need, register a custom component. It works without forking the framework and without a Node build, because the shell ships pre-built: a custom type ships a declarative render spec the shell's generic renderer interprets at runtime, not new Svelte code.

A worked example: a colour picker

import indah

indah.register_component(
    "colorpicker",
    render={
        "tag": "input",  # one allowlisted HTML element
        "attrs": {"type": "color"},  # static attributes
        "bind": {"value": "value"},  # element attribute <- node prop
        "on": {"input": {"event": "input", "prop": "value"}},  # UI change -> signal
    },
)

colour = indah.Signal("#ff8800")
picker = indah.custom("colorpicker", value=colour)  # two-way bound, like a built-in

register_component() validates the spec once; custom(type, **props) builds value-bearing instances that round-trip exactly like a built-in component. Read the current value with colour.value - it follows what the user picked.

Try it live. Nothing downloads until you press Run it.

Register a component - register_component takes a render spec (an allowlisted tag, bindings, events); custom makes one, two-way bound to a signal.
indah.register_component(
    "colorpicker",
    render={
        "tag": "input",  # one allowlisted element
        "attrs": {"type": "color"},
        "bind": {"value": "value"},  # element value <- node prop
        "on": {"input": {"event": "input", "prop": "value"}},  # DOM event -> signal
    },
)


def build(ctx):
    accent = indah.Signal("#ff8800")
    return indah.Column(
        children=[
            indah.custom("colorpicker", value=accent),
            indah.Text(lambda: f"Accent: {accent.value}"),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/extend/register)

A composite component - A spec may nest children: a title, a meter, a readout and a range input, all fed by node props. A nested input round-trips.
indah.register_component(
    "levelcard",
    render={
        "tag": "div",
        "children": [  # a small composite: a title, a meter and a readout
            {"tag": "strong", "text": "title"},  # text content <- node prop
            {"tag": "meter", "attrs": {"min": "0", "max": "100"}, "bind": {"value": "value"}},
            {"tag": "span", "text": "value"},
            {  # a nested input round-trips too: its `on` writes the "value" signal
                "tag": "input",
                "attrs": {"type": "range", "min": "0", "max": "100"},
                "bind": {"value": "value"},
                "on": {"input": {"event": "input", "prop": "value"}},
            },
        ],
    },
)


def build(ctx):
    level = indah.Signal(40)
    return indah.Column(
        children=[
            indah.custom("levelcard", title="Battery", value=level),
            indah.Slider(level, min=0, max=100, label="Level"),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/extend/composite)

The render spec

Field Meaning
tag The HTML element to create, from a safe allowlist (no script / iframe / style)
attrs Static attributes {name: value}
class A static class string
text A node prop (in a List, an item field) whose value becomes the element's text content
literal Fixed text content, shown as written, such as "Remove" on a button; not together with text
bind {attribute: prop} - the element attribute follows the node prop, patched reactively
on {domEvent: {event, prop}} - a DOM event posts the indah event; prop names the signal the value is written into
children Nested render specs, for a small composite. A nested spec's on round-trips like the top-level one

What the spec can and cannot do

The spec is data, not code, and the tag set is inert, so a custom component cannot inject a script or fetch remote code. That keeps indah's dev-tool threat model intact - registering a component never widens the attack surface.

No loops or conditionals, by design

The spec deliberately cannot express loops or conditionals. A component that needs those is the signal to reach for a hand-written frontend against the protocol, not a larger spec language.

Validation is strict: a disallowed tag, a type that collides with a built-in, an empty type, or a malformed spec is rejected with a ValueError at registration time, not at render time.

How it travels

The spec rides on the wire as a reserved _spec prop, so it is part of the public protocol. The shell's generic renderer reads _spec, builds the element, binds its attributes to node props, and wires its DOM events back to your signals - all at runtime, with no rebuild.

A component with its own JavaScript

A render spec is one element tree. When you need a drawing, per-item state or your own events, ship an ES module instead (ADR-0039). The module is your own finished file; indah needs no Node and does not build it.

indah.register_component(
    "tier-rail",
    module="modules/tier-rail.js",  # a file or bytes, read once at registration; never a URL
    events=["pick"],  # CustomEvents forwarded to Python
    two_way={"value": "change"},  # detail.value of "change" writes the `value` signal
    fallback="steps",  # shown as text if the module cannot load or run
)

rail = indah.custom(
    "tier-rail",
    steps=["Seed", "Sprout", "Bloom"],  # props arrive as properties
    value=tier,  # a Signal: set from Python, and written by "change"
    on_pick=lambda detail: print(detail["label"]),  # may be async; guard() and audit apply
)

The module's default export is a class extending HTMLElement. indah defines it as indah-x-<type>, sets each prop as a property (before the element is attached, and again on every change), and forwards the events you listed. examples/studio/modules/tier-rail.js is a complete one.

export default class TierRail extends HTMLElement {
  set steps(v) { /* draw the steps with textContent, not innerHTML */ }
  pick(i) {
    this.dispatchEvent(new CustomEvent("pick", { detail: { index: i } }));
  }
}

Things to know:

  • Theming. CSS variables pass through a shadow root, so use the shell's tokens (--surface, --text, --muted, --primary, --outline, --radius, --font-body) and the module follows light and dark with no code.
  • Styles under a strict CSP. Use a constructable stylesheet (adoptedStyleSheets), or a <style> carrying el.cspNonce, which the shell sets before the element is attached. A bare <style> is refused by csp="strict".
  • Props are data. Put them in with textContent; assigning one to innerHTML is an XSS hole you would create yourself.
  • Trust. The module runs in the viewer's page with your app's origin. Register only files you wrote or vendored; never one a user supplied. Every session can load every registered module, so keep secrets out of the source.
  • Failure. If the module cannot load, has no default export, or throws, the shell logs one console error and shows the fallback prop as plain text; the rest of the page keeps working.
  • Limits. 1 MB per module and 64 module types per process. The type is lowercase words joined by hyphens. A module component cannot yet sit inside a List row or a table cell.

Try it live. Nothing downloads until you press Run it.

A module component - A component that ships its own ES module: props arrive as properties, CustomEvents come back as handlers and two-way values. The module is served by the app itself (modules/tier-rail.js), themed with the shell's variables.
indah.register_component(
    "tier-rail",
    module="modules/tier-rail.js",  # a file or bytes, read once; never a URL
    events=["pick"],  # CustomEvents forwarded to Python
    two_way={"value": "change"},  # detail.value of "change" writes the `value` signal
    fallback="steps",  # shown as text if the module cannot run
)


def build(ctx):
    steps = indah.Signal(["Seed", "Sprout", "Bloom"])
    tier = indah.Signal(0)
    picked = indah.Signal("nothing yet")
    return indah.Column(
        children=[
            indah.custom(
                "tier-rail",
                label="Tier",
                steps=steps,
                value=tier,
                on_pick=lambda d: picked.set(d["label"]),
            ),
            indah.Text(lambda: f"Tier {tier.value}, last pick: {picked.value}"),
            indah.Button("Next tier", lambda: tier.set((tier.value + 1) % len(steps.value))),
            indah.Button("Add a tier", lambda: steps.set([*steps.value, f"Tier {len(steps.value)}"])),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/extend/bridge)