Skip to content

Components

Every component is a Python object you place in a tree and bind to a signal. The sections below walk through the common ones with runnable snippets; this is the full set shipped so far.

The full set

Text and display

Component Use
Text a label bound to a signal, computed, or string
Marked text with shaded spans (the part that is ignored or flagged): Marked(text, marks=[(start, end, tone)])
Steps a timeline of steps, vertical or horizontal: Steps(items, orientation=); each step is done, current, skipped, off or pending
Image a URL, data: URI, or raw PNG bytes
ImageOverlay an image with read-only vector overlays (boxes, masks, keypoints)
Audio a URL, data: URI, or raw audio bytes, played with native browser controls
Try these live (6 examples)
Text roles - Plain Text is body text. variant= picks a role: title, label, caption, mono, or the large readout for a figure.
def text_roles(ctx: indah.Context) -> indah.Component:
    return indah.Column(
        gap="0.5rem",
        children=[
            indah.Text("Body: the default, for paragraphs, status lines and notes."),
            indah.Text("Title: a heading for a block", variant="title"),
            indah.Text("Label: the name of a field", variant="label"),
            indah.Text("Caption: help text and fine print", variant="caption"),
            indah.Text("mono: ids, paths, aligned numbers", variant="mono"),
            indah.Text("1,284", variant="readout"),
            indah.Text("Two lines,\nkept as written."),
        ],
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/start/text-roles)

Marked text - Marked(text, marks=[(start, end, tone)]) shades spans of a text, for the part that is ignored or flagged. The spans add no characters, so copying gives back the plain text, and each tone has its own underline as well as its colour. In a list item or a table cell, put marked_runs(text, marks) in a field and name {"component": "marked"} in the render spec.
def marked(ctx: indah.Context) -> indah.Component:
    ignored = indah.Signal(True)
    # Offsets count characters (Unicode code points); the last item is a label shown on hover.
    start = REQUEST.index("Ignore")
    marks = indah.Computed(
        lambda: (
            [(0, 9, "info", "the task")]
            + ([(start, len(REQUEST), "warn", "ignored")] if ignored.value else [])
        )
    )
    cell = {"component": "marked", "bind": {"runs": "runs"}}
    rows = [
        {"id": "a", "runs": indah.marked_runs("GET /v1/models 200", [(4, 14, "ok")])},
        {"id": "b", "runs": indah.marked_runs("POST /v1/chat 500", [(5, 13, "err", "failed")])},
    ]
    return indah.Column(
        children=[
            indah.Marked(REQUEST, marks, label="Request"),
            indah.Button(
                lambda: "Stop ignoring the header" if ignored.value else "Ignore the header",
                lambda: ignored.set(not ignored.value),
            ),
            indah.List(rows, key="id", item=cell),
            indah.Table(
                columns=[
                    indah.Col("id"),
                    indah.Col("runs", title="Request", kind="cell", render=cell),
                ],
                rows=rows,
                key="id",
            ),
        ]
    )

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

Steps - Steps(items) draws a timeline, vertical or horizontal: each step is done, current, skipped, off or pending, with its own shape as well as its colour, so it reads without colour. It follows a signal in place. In a list item or a table cell, keep steps_items(...) in a field and name {"component": "steps"} in the render spec: it is compact there.
def steps(ctx: indah.Context) -> indah.Component:
    stage = indah.Signal(1)
    names = ["Plan", "Build", "Test", "Ship"]

    def timeline() -> list:
        out = []
        for i, name in enumerate(names):
            state = "done" if i < stage.value else "current" if i == stage.value else "pending"
            out.append({"label": name, "state": state, "detail": f"stage {i + 1}"})
        return out

    # In a list item or a table cell the timeline is compact and horizontal.
    cell = {"component": "steps", "bind": {"items": "tiers"}}
    tiers = indah.steps_items(
        [("exact", "done", "hit"), ("fuzzy", "skipped"), ("llm", "current"), ("manual", "off")]
    )
    rows = [{"id": "a", "tiers": tiers}, {"id": "b", "tiers": tiers}]
    return indah.Column(
        children=[
            indah.Steps(timeline, label="Release"),
            indah.Steps(timeline, orientation="horizontal", label="Release flow"),
            indah.Button("Next stage", lambda: stage.set(min(stage.value + 1, len(names)))),
            indah.List(rows, key="id", item=cell),
            indah.Table(
                columns=[
                    indah.Col("id"),
                    indah.Col("tiers", title="Tiers", kind="cell", render=cell),
                ],
                rows=rows,
                key="id",
            ),
        ]
    )

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

Image - A URL, a data: URI or raw PNG bytes. Change the source and the picture follows.
def image_page(ctx: indah.Context) -> indah.Component:
    sources = {
        "Bytes": scene_png(),
        "Data URI": "data:image/svg+xml;utf8,"
        "<svg xmlns='http://www.w3.org/2000/svg' width='160' height='120'>"
        "<rect width='160' height='120' fill='%235b5bd6'/>"
        "<circle cx='80' cy='60' r='34' fill='%23ffd65a'/></svg>",
    }
    choice = indah.Signal("Bytes")
    return indah.Column(
        children=[
            indah.Radio(choice, options=list(sources), label="Source"),
            indah.Image(lambda: sources[choice.value], alt="the picture"),
        ]
    )

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

Image overlay - Boxes, keypoints and a mask drawn over an image, in fractions so they scale.
def overlay_page(ctx: indah.Context) -> indah.Component:
    boxes = [{"x": 0.22, "y": 0.5, "w": 0.3, "h": 0.32, "label": "house", "score": 0.93}]
    points = [{"x": 0.81, "y": 0.25, "label": "sun", "color": "#ffb300"}]
    mask = [{"src": mask_png(), "opacity": 0.5}]
    show_boxes, show_points, show_mask = (indah.Signal(v) for v in (True, True, False))
    return indah.Column(
        children=[
            indah.ImageOverlay(
                scene_png(),
                boxes=lambda: boxes if show_boxes.value else [],
                points=lambda: points if show_points.value else [],
                masks=lambda: mask if show_mask.value else [],
                alt="a landscape",
            ),
            indah.Row(
                children=[
                    indah.Checkbox(show_boxes, label="Boxes"),
                    indah.Checkbox(show_points, label="Keypoints"),
                    indah.Checkbox(show_mask, label="Mask"),
                ]
            ),
        ]
    )

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

Audio - WAV bytes generated in Python and played with the browser's own controls.
def audio_page(ctx: indah.Context) -> indah.Component:
    freq = indah.Signal(440)
    return indah.Column(
        children=[
            indah.Select(freq, options=[(220, "A3 (220 Hz)"), (440, "A4 (440 Hz)")], label="Pitch"),
            indah.Audio(lambda: tone_wav(float(freq.value)), media_type="audio/wav"),
        ]
    )

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

Inputs (two-way bound to a signal)

Component Use
Slider a numeric input on a track
Number a plain numeric input with optional bounds
TextInput a single-line text box
TextArea a multi-line text box
CodeEditor a JSON / YAML / text editor: highlighting, auto-indent, auto-closing brackets and quotes, folding, search, JSON lint
Switch an on/off toggle bound to a Signal[bool]
Select a single-choice dropdown
MultiSelect a list of checkboxes; the value is the list of checked options
Radio a radio group
Chips filter chips, single (Signal[str]) or multi=True (Signal[list]); an option may carry a count and a tone
Segmented a joined row of options, one selected; tones={value: tone} tints a segment
Checkbox a boolean checkbox
Date a date picker (ISO YYYY-MM-DD)
Try these live (3 examples)
Basic inputs - Every starter input is two-way bound to a signal; the sentence is one computed.
def inputs(ctx: indah.Context) -> indah.Component:
    name = indah.Signal("Ada")
    size = indah.Signal("Medium")
    toppings = indah.Signal(["olives"])
    spicy = indah.Signal(False)
    count = indah.Signal(2.0)
    day = indah.Signal("2026-10-22")
    summary = indah.computed(
        lambda: (
            f"{name.value} orders {int(count.value)} {size.value.lower()} pizza(s) with "
            f"{', '.join(toppings.value) or 'no toppings'}"
            f"{', spicy' if spicy.value else ''}, for {day.value}."
        )
    )
    return indah.Column(
        children=[
            indah.TextInput(name, label="Name", placeholder="Who is it for?"),
            indah.Radio(size, options=["Small", "Medium", "Large"], label="Size"),
            indah.MultiSelect(toppings, options=["olives", "basil", "onion"], label="Toppings"),
            indah.Checkbox(spicy, label="Make it spicy"),
            indah.Number(count, min=1, max=9, label="How many"),
            indah.Date(day, label="Day"),
            indah.Text(lambda: summary.value),
        ]
    )

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

TextArea and Select - Two more two-way inputs: a multi-line box and a dropdown.
def text_inputs(ctx: indah.Context) -> indah.Component:
    note = indah.Signal("Line one\nLine two")
    level = indah.Signal("info")
    return indah.Column(
        children=[
            indah.TextArea(note, label="Notes", rows=4),
            indah.Select(level, options=["info", "warn", "error"], label="Level"),
            indah.Text(
                lambda: (
                    f"{level.value.upper()}: {len(note.value.splitlines())} line(s), "
                    f"{len(note.value)} characters."
                )
            ),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/overlays/text-inputs)

Options that change - options= takes a signal, a computed or a callable: a new project appears with no reload.
def live_options(ctx: indah.Context) -> indah.Component:
    projects = indah.Signal(["alpha", "beta"])
    current = indah.Signal("beta")
    tags = indah.Signal(["alpha", "beta"])

    def add() -> None:
        projects.set([*projects.value, f"project-{len(projects.value) + 1}"])

    def drop() -> None:
        if len(projects.value) > 1:
            projects.set(projects.value[:-1])

    return indah.Column(
        gap="1rem",
        children=[
            indah.Select(current, options=projects, label="Project"),
            indah.Segmented(current, options=projects, label="The same signal"),
            indah.Chips(tags, options=projects, multi=True, label="Tags"),
            indah.Row(
                children=[
                    indah.Button("Add a project", on_click=add),
                    indah.Button("Remove the last", on_click=drop, variant="ghost"),
                ]
            ),
            indah.Text(
                lambda: f"Project {current.value}; tags {', '.join(tags.value) or 'none'}.",
            ),
        ],
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/start/live-options)

Actions

Component Use
Button an on_click handler (sync or async def); confirm= asks first
Shortcuts keyboard shortcuts for the page, with a ? help overlay

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

Buttons and async handlers - A handler may be async def: it runs in the background, so the page stays live.
def actions(ctx: indah.Context) -> indah.Component:
    clicks = indah.Signal(0)
    log = indah.Signal("Nothing yet.")

    async def slow() -> None:
        import asyncio

        log.set("Working...")
        await asyncio.sleep(0.6)
        log.set("Done after an async handler (the page stayed live while it ran).")

    return indah.Column(
        children=[
            indah.Row(
                children=[
                    indah.Button("Count", lambda: clicks.set(clicks.value + 1)),
                    indah.Button("Run async work", slow, variant="tonal"),
                    indah.Button("Reset", lambda: clicks.set(0), variant="ghost"),
                ]
            ),
            indah.Text(lambda: f"Clicked {clicks.value} time(s)."),
            indah.Text(lambda: log.value),
        ]
    )

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

Layout

Component Use
Column a vertical stack; gap= sets the spacing
Row a horizontal flex row that wraps; children share the width, unless align= is set to anything but "stretch" (buttons and badges size to their content) or justify= ("start", "center", "end", "between", "around") is set (every child sizes to its content)
Spacer free space that grows to fill a Row, pushing what follows to the far end; Spacer(size="2rem") is a fixed gap
Divider a thin rule; Divider(vertical=True) between the children of a Row
Grid an N-column grid (or a track list such as "760px 1fr", with optional rows) that collapses to one column on phones
Cell a grid child that spans several columns or rows
Frame a fixed design-size stage (say 1920 by 1080) scaled to fit the window, so a dashboard never scrolls
Card a titled surface panel; variant="flat" drops the frame, "tonal" tints it, padding= sets the inner space
Tabs a tabbed container, one panel at a time
Sidebar a persistent side region plus a main region
AppShell the app frame: a rail that stays put while the page scrolls, an optional top bar and the main region (rail=, main=, top=, rail_width=); a drawer behind a menu button on a narrow screen
Expander a collapsible labelled section

A Frame is for a dashboard that has to fit one screen, such as one you will screen-record. Lay the pieces out once at a design size, say 1920 by 1080, and indah scales the whole thing to the window:

Frame and Cell - Frame lays children out once in a fixed design box and scales it to fit, so a dashboard is the same at any window size; Grid tracks and Cell spans place the pieces.
def frame(ctx: indah.Context) -> indah.Component:
    def tile(title: str, text: str) -> indah.Component:
        return indah.Card(title=title, padding="0.75rem", children=[indah.Text(text)])

    return indah.Frame(
        width=800,
        height=450,
        fit="width",
        columns="200px 1fr 1fr",
        rows="60px 1fr 90px",
        gap="12px",
        padding="12px",
        children=[
            indah.Cell(columns=3, children=[tile("Header", "A row of the frame, 60px tall")]),
            indah.Cell(rows=2, children=[tile("Rail", "200px wide, two rows tall")]),
            tile("Body", "1fr"),
            tile("Side", "1fr"),
            indah.Cell(columns=2, children=[tile("Footer", "Spans two columns")]),
        ],
    )

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

| SplitPane | two panels with a draggable divider | | MasterDetail | a list beside its detail, a drawer on a narrow screen | | Router / Link | pages chosen by the URL, and links between them | | NavItem | a Link for a rail with an icon= and a live count badge= that hides at zero | | Modal / Drawer | a dialog and a side panel, opened from a signal | | CommandPalette / Command | a keyboard palette opened with Ctrl/Cmd+K: fuzzy filter, run a function or jump to a page, confirm=, per-page commands | | Menu / MenuItem | a menu button of actions with arrow keys, confirm=, danger= and disabled= items |

Try these live (5 examples)
Row, Grid and Card - Row lines children up, Grid(columns=3) wraps them, Card frames a group.
def containers(ctx: indah.Context) -> indah.Component:
    def stat(title: str, value: str, *, figure: bool = False) -> indah.Component:
        text = indah.Text(value, variant="readout") if figure else indah.Text(value)
        return indah.Card(title=title, children=[text])

    return indah.Column(
        children=[
            indah.Text("A Row puts cards side by side:"),
            indah.Row(
                children=[stat("Requests", "1.2k", figure=True), stat("Errors", "0", figure=True)]
            ),
            indah.Text("A Grid wraps them into columns:"),
            indah.Grid(
                columns=3,
                gap="0.75rem",
                children=[stat(f"Tile {n}", f"value {n}") for n in range(1, 7)],
            ),
        ]
    )

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

Spacer, Divider and Card variants - Row(justify=) or a Spacer splits a header; Divider rules; Card(variant=) softens the frame.
def spacing(ctx: indah.Context) -> indah.Component:
    def export() -> indah.Component:
        return indah.Button("Export", on_click=lambda: None, variant="ghost")

    return indah.Column(
        gap="1.25rem",
        children=[
            indah.Text(
                'Title on the left, actions on the right, with `justify="between"`:', markdown=True
            ),
            indah.Row(
                align="center",
                justify="between",
                children=[indah.Text("Requests"), export()],
            ),
            indah.Text("The same with a Spacer that takes the free width:"),
            indah.Row(
                align="center",
                children=[indah.Text("Errors"), indah.Spacer(), export()],
            ),
            indah.Divider(),
            indah.Row(
                align="center",
                justify="start",
                gap="0.75rem",
                children=[
                    indah.Text("Left"),
                    indah.Divider(vertical=True),
                    indah.Text("Right"),
                ],
            ),
            indah.Row(
                children=[
                    indah.Card(title="Outlined", children=[indah.Text("The default")]),
                    indah.Card(title="Flat", variant="flat", children=[indah.Text("No frame")]),
                    indah.Card(
                        title="Tonal",
                        variant="tonal",
                        padding="0.75rem",
                        children=[indah.Text("A tinted fill")],
                    ),
                ]
            ),
        ],
    )

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

Tabs and Expander - Both take a signal, so server code can switch a tab or open a section too.
def tabs(ctx: indah.Context) -> indah.Component:
    active = indah.Signal(0)
    more = indah.Signal(False)
    return indah.Column(
        children=[
            indah.Tabs(
                labels=["Overview", "Data", "About"],
                active=active,
                children=[
                    indah.Text("The overview tab."),
                    indah.Text("The data tab."),
                    indah.Text("The about tab."),
                ],
            ),
            indah.Text(lambda: f"Active tab index: {active.value}"),
            indah.Button("Jump to About", lambda: active.set(2), variant="tonal"),
            indah.Expander(
                label="More detail",
                open=more,
                children=[indah.Text("Hidden until the expander opens.")],
            ),
            indah.Text(lambda: "Expander is open." if more.value else "Expander is closed."),
        ]
    )

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

Sidebar - Inputs on the left, output on the right; on a narrow screen it stacks.
def sidebar(ctx: indah.Context) -> indah.Component:
    name = indah.Signal("Ada")
    loud = indah.Signal(False)
    return indah.Sidebar(
        children=[
            indah.Column(
                children=[
                    indah.TextInput(name, label="Your name"),
                    indah.Checkbox(loud, label="Shout it"),
                ]
            ),
            indah.Card(
                title="Output",
                children=[
                    indah.Text(
                        lambda: f"Hello, {name.value.upper() if loud.value else name.value}!"
                    )
                ],
            ),
        ]
    )

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

AppShell - A pinned rail beside a scrolling main, with a top bar; a drawer on a narrow screen.
def appshell(ctx: indah.Context) -> indah.Component:
    section = indah.Signal("Overview")
    sections = ["Overview", "Requests", "Settings"]

    def nav(name: str) -> indah.Component:
        return indah.Button(
            name,
            lambda: section.set(name),
            variant="tonal" if name == "Overview" else "ghost",
        )

    rail = indah.Column(
        gap="0.5rem",
        children=[indah.Text("indah app", variant="title"), *[nav(n) for n in sections]],
    )
    top = indah.Row(
        align="center",
        justify="between",
        children=[
            indah.Text(lambda: f"Section: {section.value}"),
            indah.Button("Refresh", lambda: None, variant="ghost"),
        ],
    )
    filler = [indah.Text(f"Row {i}: the rail stays while this column scrolls.") for i in range(40)]
    return indah.AppShell(
        rail=rail,
        top=top,
        main=[indah.Card(title="Main", children=filler)],
        rail_width="220px",
    )

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

Charts and data

Component Use
Plot a Matplotlib figure rasterised to a PNG on the Python side
Chart an interactive client-side chart (zoom, hover, live points)
Heatmap a client-side heatmap / 2-D field
Map an interactive client-side map (Leaflet): pan/zoom, markers, polygons, polylines
Graph an interactive node-link graph: pan/zoom, click to select, a focus window for big graphs (guide)
DataFrame a pandas frame, a {columns, rows} dict, or record dicts
Table an interactive table: sort, page, and select
Pie a pie chart with one slice lit: pulled out and glowing while the rest dim
Stat a metric / KPI card: a big value with a label and optional delta
BarStrip a small activity strip: one bar per interval (calls per minute), optionally stacked, with a tooltip per bar
InfoTip a small focusable info icon that shows a short note on hover, focus or tap
JsonTree a collapsible, searchable JSON viewer
Diff a split or unified diff of two texts or JSON values

Map loads Leaflet the first time a map appears (one request, about 43 KB compressed), the way CodeEditor loads its editor, so an app without a map never downloads it. If it cannot load, the map area says so and the rest of the page keeps working.

Try these live (8 examples)
Table: the classic form - Pass rows, get a sortable, paged table. A click sets a signal with the row index.
def table_classic(ctx: indah.Context) -> indah.Component:
    picked = indah.Signal(None)
    return indah.Column(
        children=[
            indah.Table(PEOPLE, selected=picked, page_size=4, label="People"),
            indah.Text(
                lambda: (
                    "Click a row."
                    if picked.value is None
                    else f"Selected row {picked.value}: {PEOPLE[picked.value]['name']}"
                )
            ),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/data/table-classic)

DataFrame - The plain display table: a pandas frame, a dict or a list of rows, in a signal.
def dataframe(ctx: indah.Context) -> indah.Component:
    rows = indah.Signal(3.0)
    table = indah.computed(lambda: PEOPLE[: int(rows.value)])  # a list of dicts, or a pandas frame
    return indah.Column(
        children=[
            indah.Slider(rows, min=1, max=6, step=1, label="Rows"),
            indah.DataFrame(lambda: table.value, label="People"),
        ]
    )

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

Stat tiles - A big value with a label and a delta (green if it does not start with -).
def stat(ctx: indah.Context) -> indah.Component:
    hour = indah.Signal(0)
    requests = [1200, 1340, 1105, 1580]
    errors = [12, 9, 31, 7]
    pick = lambda xs: xs[hour.value % len(xs)]  # noqa: E731
    delta = lambda xs: f"{xs[hour.value % len(xs)] - xs[(hour.value - 1) % len(xs)]:+d}"  # noqa: E731
    return indah.Column(
        children=[
            indah.Row(
                children=[
                    indah.Stat(
                        lambda: f"{pick(requests):,}",
                        label="Requests",
                        delta=lambda: delta(requests),
                    ),
                    indah.Stat(
                        lambda: pick(errors),
                        label="Errors",
                        delta=lambda: delta(errors),
                        help="Per hour",
                    ),
                ]
            ),
            indah.Button("Next hour", lambda: hour.set(hour.value + 1)),
        ]
    )

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

Activity strip - BarStrip(series, tones=, names=, labels=): one bar per interval, stacked when a bar is a tuple. It updates in place as minutes arrive. Hover a bar, or focus the strip and use the arrow keys, to read it.
def barstrip(ctx: indah.Context) -> indah.Component:
    rng = random.Random(7)  # the same minutes every time
    minutes = indah.Signal([(rng.randint(2, 9), rng.randint(0, 3)) for _ in range(24)])
    clock = indah.Signal(24)  # minutes shown so far, for the labels

    def tick() -> None:
        clock.set(clock.value + 1)
        minutes.set([*minutes.value[-29:], (rng.randint(2, 9), rng.randint(0, 3))])

    ctx.every(1.0, tick)  # a new "minute" each second; stops with the tab
    return indah.Column(
        children=[
            indah.Text(
                lambda: "Latest minute: hits {}, misses {}".format(*minutes.value[-1]),
                variant="label",
            ),
            indah.BarStrip(
                minutes,
                tones=["ok", "err"],
                names=["hit", "miss"],
                labels=lambda: [
                    f"min {clock.value - len(minutes.value) + i + 1}"
                    for i in range(len(minutes.value))
                ],
                label="Calls per minute",
                height=48,
            ),
            indah.Text("One series, a single tone:", variant="caption"),
            indah.BarStrip([3, 5, 2, 8, 6, 0, 4], tones=["info"], label="Queue depth", height=24),
        ]
    )

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

Live chart - Each push sends only the new row; the browser (uPlot) appends it and keeps zoom.
def live_chart(ctx: indah.Context) -> indah.Component:
    chart = indah.Chart(series=["signal"], title="Live signal", x_label="tick", points=True)
    rng = random.Random(7)
    ticks = indah.Signal(0)

    def add(n: int) -> None:
        for _ in range(n):
            chart.push(ticks.value, math.sin(ticks.value / 6) + rng.uniform(-0.2, 0.2))
            ticks.set(ticks.value + 1)

    def reset() -> None:
        chart.clear()
        ticks.set(0)

    add(30)  # start with a curve already on screen

    return indah.Column(
        children=[
            chart,
            indah.Row(
                children=[
                    indah.Button("Add 10 points", lambda: add(10)),
                    indah.Button("Clear", reset, variant="ghost"),
                ]
            ),
            indah.Text(lambda: f"{ticks.value} points pushed (only the new rows travel)."),
        ]
    )

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

A series can be bars or dots, a playhead dims what has not happened yet, a highlight picks out one bar, and a rule marks a target:
Bars, dots and markers - A series can be bars or dots. A playhead dims what has not happened yet, a highlight picks out one bar, and a rule marks a target. Colours written as var(--token) follow the light and dark switch.
def chart_kinds(ctx: indah.Context) -> indah.Component:
    rng = random.Random(3)
    n = 60
    decay = [[i, round(0.97**i, 4)] for i in range(1, n + 1)]
    dots = [
        [i, round(40 + 55 * (1 - 0.97**i) + rng.uniform(-12, 12), 1), None] for i in range(1, n + 1)
    ]
    for row in dots[::7]:
        row[1], row[2] = None, row[1] - 25  # a few runs that did badly
    at = indah.Signal(30)

    return indah.Column(
        children=[
            indah.Slider(at, min=1, max=n, step=1, label="Current episode"),
            indah.Chart(
                decay,
                series=[{"label": "decay", "kind": "bars", "stroke": "var(--primary)"}],
                title="Bars, with a playhead, the future dimmed and the current bar highlighted",
                x_range=(0.5, n + 0.5),
                y_range=(0, 1),
                playhead=at,
                shade_after=True,
                highlight=at,
                highlight_color="var(--ok)",
                legend="off",
                height=170,
            ),
            indah.Chart(
                dots,
                series=[
                    {"label": "good", "kind": "scatter", "stroke": "var(--primary)", "size": 6},
                    {"label": "poor", "kind": "scatter", "stroke": "var(--muted)", "size": 6},
                ],
                title="Scatter dots and a dashed reference rule",
                x_range=(0.5, n + 0.5),
                y_range=(0, 110),
                rules=[{"y": 90, "label": "target 90", "color": "var(--ok)"}],
                legend="labels",
                height=170,
            ),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/media/chart-kinds)

Heatmap - A 2-D field drawn on a canvas. Move the slider and the whole field is replaced.
def heatmap(ctx: indah.Context) -> indah.Component:
    freq = indah.Signal(2.0)
    return indah.Column(
        children=[
            indah.Heatmap(
                lambda: ripple(freq.value),
                colormap="viridis",
                zmin=-1,
                zmax=1,
                title="Ripple",
                x_label="x",
                y_label="y",
            ),
            indah.Slider(freq, min=1, max=6, step=1, label="Frequency"),
            indah.Text(lambda: f"Frequency {freq.value:g}"),
        ]
    )

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

A heatmap can take its own colour stops, leave a cell with no value blank, label rows and runs of columns, and spotlight one cell:
Heatmap labels and marks - A colormap can be your own colour stops, a cell with no value stays blank, rows and runs of columns can be labelled, and one cell can be spotlit (mark_x, mark_y).
def heatmap_marks(ctx: indah.Context) -> indah.Component:
    state = indah.Signal(12)
    action = indah.Signal(2)
    cols = 24
    field = [
        [None if (x * 5 + y * 3) % 7 < 2 else round(math.sin(x / 3 + y), 2) for y in range(4)]
        for x in range(cols)
    ]  # None is a cell with no value, drawn blank rather than as the lowest colour
    return indah.Column(
        children=[
            indah.Row(
                children=[
                    indah.Slider(state, min=0, max=cols - 1, step=1, label="State"),
                    indah.Slider(action, min=0, max=3, step=1, label="Action"),
                ]
            ),
            indah.Heatmap(
                field,
                colormap=["var(--err)", "var(--surface-2)", "var(--ok)"],
                zmin=-1,
                zmax=1,
                null_color="var(--surface)",
                origin="top",
                row_labels=["up", "down", "left", "right"],
                col_groups=[
                    {"start": 0, "end": 11, "label": "first half"},
                    {"start": 12, "end": 23, "label": "second half"},
                ],
                mark_x=state,
                mark_y=action,
                height=190,
                title="Blank cells, labels, groups and a marked cell",
            ),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/media/heatmap-marks)

A `Pie` lights one slice, pulled out and glowing while the rest dim:
Pie with a lit slice - A pie that lights one slice: it is pulled out and glows while the others dim. Use it to show which of several outcomes just happened.
def pie_lit(ctx: indah.Context) -> indah.Component:
    lit = indah.Signal(1)
    epsilon = 0.127
    return indah.Column(
        children=[
            indah.Pie(
                [
                    {"label": "explore", "value": epsilon, "color": "var(--warn)"},
                    {"label": "exploit", "value": 1 - epsilon, "color": "var(--primary)"},
                ],
                active=lit,
                height=170,
            ),
            indah.Row(
                children=[
                    indah.Button("Explore", lambda: lit.set(0), variant="ghost"),
                    indah.Button("Exploit", lambda: lit.set(1), variant="ghost"),
                    indah.Button("None", lambda: lit.set(None), variant="ghost"),
                ]
            ),
        ]
    )

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

Map - Leaflet with markers and polygons. Tiles load from OpenStreetMap in your own browser, so offline you see the shapes on a blank map.
def map_page(ctx: indah.Context) -> indah.Component:
    city = indah.Signal("Paris")
    center = indah.computed(lambda: CITIES[city.value])
    pin = indah.computed(
        lambda: [{"lat": center.value[0], "lon": center.value[1], "label": city.value}]
    )
    lat, lon = CITIES["Paris"]
    area = [{"points": [(lat + 0.02, lon - 0.03), (lat + 0.02, lon + 0.03), (lat - 0.02, lon)]}]
    return indah.Column(
        children=[
            indah.Map(center, zoom=11, markers=pin, polygons=area, height=300, label="City map"),
            indah.Select(city, options=list(CITIES), label="Fly to"),
            indah.Text(lambda: f"Centred on {city.value}."),
        ]
    )

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

Plot - A rendered figure shown as an image. Pass a Matplotlib Figure or, as here, PNG bytes.
def plot_page(ctx: indah.Context) -> indah.Component:
    return indah.Plot(curve_png(), alt="damped sine")

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

Streaming and data-driven lists

Component Use
StreamText a string that grows token by token over SSE
List a data-driven list bound to a Signal[list]; key= and on_item= make rows editable
Chat a chat transcript of role bubbles bound to a Signal[list]
Gallery an image grid bound to a Signal[list]

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

Gallery - An image grid bound to a list signal; append to the list to grow it.
def gallery_page(ctx: indah.Context) -> indah.Component:
    images = indah.Signal([tile_png(i) for i in range(3)])

    def grow() -> None:
        images.set([*images.value, tile_png(len(images.value))])

    return indah.Column(
        children=[
            indah.Gallery(images, columns=3, label="Swatches"),
            indah.Button("Add a tile", grow),
        ]
    )

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

Files and feedback

Component Use
Upload a file input (image / audio / any file)
Download a link that hands the user a file back
Progress a progress bar bound to a source
Spinner an indeterminate busy indicator
Skeleton grey placeholder bars for content that has not arrived (lines=, height=); an async session factory that is still running shows one
Badge a small status pill
Empty what to show where there is nothing yet: Empty(title, body, action=, children=)
Code a read-only block of code or a command with a Copy button: Code(text, language=, copy=True)
SecretReveal shows a secret (a new API token) once, with Copy and dismiss; never replayed on reconnect (see Host an app)
Try these live (5 examples)
Empty state and Code - Empty(title, body, action=, children=) says what to do when there is nothing yet. Code(text, language=, copy=True) is a read-only block whose Copy button puts the plain text on the clipboard and shows a toast.
def empty_and_code(ctx: indah.Context) -> indah.Component:
    output = indah.Signal("")

    def send() -> None:
        output.set("GET /v1/models 200 12ms")

    return indah.Column(
        children=[
            indah.Empty(
                "No requests yet",
                "Start the proxy and every request appears here.",
                children=[indah.Code("semula log --follow", language="bash")],
                action=indah.Button("Send a test request", send),
            ),
            indah.Code(lambda: output.value or "(nothing yet)", language="output", copy=False),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/data/empty-code)

Skeleton: a placeholder while data loads - Grey pulsing bars for content that has not arrived. An async def session factory that takes too long starts the tab on one too (see the Async start app).
def skeleton(ctx: indah.Context) -> indah.Component:
    loaded = indah.Signal(False)
    ctx.spawn(_finish_loading(loaded))  # data "arrives" after a moment
    return indah.Column(
        children=[
            indah.Skeleton(lines=1, height="1.6rem"),  # a title
            indah.Skeleton(),  # three lines, the last one shorter
            indah.Text(
                lambda: (
                    "Loaded: the real content replaces the placeholder."
                    if loaded.value
                    else "Loading..."
                )
            ),
        ]
    )

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

Progress and Spinner - An async handler writes a signal as it goes; the bar and the spinner follow it.
def progress(ctx: indah.Context) -> indah.Component:
    done = indah.Signal(0.0)
    busy = indah.Signal(False)

    async def run() -> None:
        busy.set(True)
        done.set(0.0)
        for step in range(1, 11):
            await asyncio.sleep(0.08)
            done.set(step / 10)
        busy.set(False)
        ctx.toast("All 10 steps finished", kind="success")

    return indah.Column(
        children=[
            indah.Button("Start job", run),
            indah.Spinner(active=busy, label="Working"),
            indah.Progress(done, label="Job progress"),
            indah.Text(lambda: f"{round(done.value * 100)}% done"),
        ]
    )

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

Upload - on_upload receives an UploadedFile with its name, type and bytes.
def upload_page(ctx: indah.Context) -> indah.Component:
    report = indah.Signal("No file yet.")

    def on_upload(file: indah.UploadedFile) -> None:
        lines = len(file.text(errors="replace").splitlines())  # a stray byte will not raise
        report.set(
            f"Got {file.filename} ({file.content_type}), {file.size} bytes, {lines} line(s)."
        )

    return indah.Column(
        children=[
            indah.Upload(on_upload, label="Choose a text file", accept=".txt,.csv"),
            indah.Text(lambda: report.value),
        ]
    )

This example needs a server: a multipart upload is posted to api/upload, which the browser runtime does not provide. Run make demo to try it. Open in the Studio (make demo, then c/media/upload)

Download - A computed DownloadFile becomes a per-session link; change the rows, get a new file.
def download_page(ctx: indah.Context) -> indah.Component:
    rows = indah.Signal(3.0)
    report = indah.computed(
        lambda: indah.DownloadFile(
            b"n,square\n" + "".join(f"{n},{n * n}\n" for n in range(int(rows.value))).encode(),
            filename="squares.csv",
            media_type="text/csv",
        )
    )
    return indah.Column(
        children=[
            indah.Number(rows, min=1, max=20, label="Rows"),
            indah.Download(report, label="Download squares.csv"),
        ]
    )

This example needs a server: a download is served from api/file/..., a route the browser runtime does not provide. Run make demo to try it. Open in the Studio (make demo, then c/media/download)

Every component also takes tooltip="...", and ctx.toast(message) shows a transient message in the current tab. For the live-data, table, overlay and routing features, see Dashboard recipes.

Display

from indah import Text, Image, DataFrame, Column, Signal, computed

name = Signal("Ada")
page = Column(
    children=[
        Text("a static label"),
        Text(computed(lambda: f"hello, {name.value}")),  # reactive
        Image("https://example.com/logo.png", alt="logo"),
        DataFrame({"columns": ["n", "n^2"], "rows": [[k, k * k] for k in range(1, 6)]}),
    ]
)

Text accepts a string, a Signal, a computed, or a plain lambda - anything that reads signals reactively.

Plain Text is body text: normal size and weight, with line breaks kept. Pick a role with variant=:

variant Use
"body" (default) paragraphs, status lines, notes
"title" a heading for a block that is not Markdown
"label" the name of a field or group (muted)
"caption" help text, timestamps, fine print (muted)
"mono" ids, paths, numbers that must line up
"readout" a counter or a figure: large, semi-bold, tabular numbers

Plain text never interprets markup, so backticks stay literal; use Text(..., markdown=True) for code, emphasis, links and headings (a Markdown text takes no variant). size="body" and size="readout" from 0.4.0 still work and mean variant="body" and variant="readout".

Marked text

Marked(text, marks) shows a text with some spans shaded, such as the part of a request that was ignored when matching or the words a check flagged:

from indah import Marked

Marked(
    "Summarise the report. Ignore the template header.",
    [(0, 9, "info", "the task"), (22, 49, "warn", "ignored")],
)

A mark is (start, end, tone) or (start, end, tone, label). text and marks can each be a string or list, a Signal, a Computed or a function, so the shading follows the data. The tones are the theme's: ok, warn, err, info, and neutral for a plain highlight. A tone is never colour alone, since each has its own underline (solid, dashed, wavy, dotted), and the optional label is the span's hover text. The spans are real inline elements that add no characters, so selecting and copying gives back the plain text, and newlines are kept. label= on Marked names the block for a screen reader.

Offsets count characters as Python does (Unicode code points, end exclusive), so "a\U0001f600b" has the emoji at offset 1. The rules are fixed so the same input always gives the same result: a range is clamped to the text, an empty or reversed range is dropped, marks are taken in order of start (then end), and a mark that overlaps an earlier one is cut to begin where the earlier one ends (and dropped if nothing is left). A tone that is not one of the five, or an offset that is not an integer, raises an error.

To show it in a List item or a table cell, compute the runs with marked_runs(text, marks), keep them in a field, and name the component in the render spec:

from indah import Col, List, Table, marked_runs

rows = [{"id": "a", "runs": marked_runs("GET /v1/models 200", [(4, 14, "ok")])}]
cell = {"component": "marked", "bind": {"runs": "runs"}}

List(rows, key="id", item=cell)
Table(
    columns=[Col("id"), Col("runs", title="Request", kind="cell", render=cell)], rows=rows, key="id"
)

A Diff draws its own changed characters; to show shaded text beside one, put a Marked in the other pane of a Row or SplitPane. marked_runs returns a list that a List item or a table row carries as it is (other lists and dicts in an item field are shown as text).

Steps

Steps(items) draws a timeline: a pipeline, a checklist, the tiers a request went through.

from indah import Steps

Steps(
    [
        {"label": "Plan", "state": "done", "detail": "2 min"},
        ("Build", "current"),
        ("Test", "pending"),
        "Ship",
    ],
    orientation="vertical",
)

An item is a dict (label, state, detail), a tuple (label, state) or (label, state, detail), or a bare label. The state is done, current, skipped, off or pending (the default), and anything else raises an error. items can be a list, a Signal, a Computed or a function, so the timeline follows the data and updates in place. At most 100 steps are drawn.

A state is never colour alone. Each has its own drawing (a check, a ring with a dot, a barred ring, a dashed ring, an empty ring), a skipped label is struck through, and a screen reader hears "Build, step 2 of 4, current" followed by the detail. The current step has aria-current="step". The steps are display only, so they are not a tab stop. label= names the timeline for assistive technology.

orientation is "vertical" (the default) or "horizontal". size="compact" is for tight places: one line, smaller, and the detail moves into the hover text (a screen reader still reads it); at the normal size the detail is shown under its label.

To show it in a List item or a table cell, build the field with steps_items and name the component in the render spec. There it is horizontal and compact, unless you bind orientation or size to fields as well:

from indah import Col, Table, steps_items

tiers = steps_items([("exact", "done", "hit"), ("fuzzy", "skipped"), ("llm", "current")])
cell = {"component": "steps", "bind": {"items": "tiers"}}

Table(
    columns=[Col("id"), Col("tiers", kind="cell", render=cell)],
    rows=[{"id": 1, "tiers": tiers}],
    key="id",
)

Empty states and code blocks

Empty is for the place a list, log or table would be when there is nothing in it yet. Say what is missing and what to do about it; children (between the text and the action) is a good home for the one command that fills it, and action is usually a Button:

from indah import Button, Code, Empty

Empty(
    "No requests yet",
    "Start the proxy and every request appears here.",
    children=[Code("semula log --follow", language="bash")],
    action=Button("Send a test request", send),
)

Code(text, language=, copy=True) shows a block of text in a monospace face with its newlines and indentation kept. text can be a string, a Signal or a callable. language is only a label in the header (there is no colouring; use CodeEditor(read_only=True) for that). The Copy button puts the plain text on the clipboard and shows a "Copied" toast in that tab, with no round trip to the server. A page that is not a secure context (plain http away from localhost) cannot use the clipboard API; then the text is selected and the toast asks for Ctrl+C. The Copy button and a long block that scrolls are both reachable with the keyboard. copy=False hides the button.

Info tips

InfoTip(text) is a small info icon for the supplementary fact that does not deserve a line of its own, such as where a number comes from. It sits next to a label:

from indah import InfoTip, Row, Text

Row(
    justify="between",
    children=[
        Text("Requests", variant="label"),
        InfoTip("Counted at the edge, so a retry counts twice.", label="About requests"),
    ],
)

The icon opens its note on hover and on keyboard focus. A click or a tap pins it open (a second one closes it), and Escape, moving focus away or a press outside closes it. Escape closes only the note, so a modal behind it stays open. The icon is one tab stop and a real button named by label (default "More information"), and the note is attached with aria-describedby, so a screen reader reads both. The note stays inside the screen, even at 390px. text may be a Signal, Computed or function, so it can follow the data. A table header takes plain text only, so put an InfoTip beside a table, or in a cell with a render spec: {"component": "infotip", "bind": {"text": "why"}}.

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

InfoTip - InfoTip(text) is a small info icon that holds a short note. It opens on hover and on keyboard focus, a tap or click pins it, Escape or a press outside closes it, and it stays on screen on a phone. The text may follow a signal.
def info_tip(ctx: indah.Context) -> indah.Component:
    unit = indah.Signal("per minute")

    # The tip follows a signal, so it can change with what the number means.
    tip = indah.Computed(
        lambda: f"Requests {unit.value}, UTC. Counted at the edge, so a retry counts twice."
    )
    return indah.Column(
        children=[
            indah.Row(
                justify="between",
                children=[
                    indah.Text("Requests", variant="label"),
                    indah.InfoTip(tip, label="About requests"),
                ],
            ),
            indah.Text(lambda: f"Showing requests {unit.value}.", variant="caption"),
            indah.Button("Use seconds", lambda: unit.set("per second")),
        ]
    )

Loads about 9 MB, then runs in your browser. Open in the Studio (make demo, then c/overlays/info-tip)

Activity strips

BarStrip is a compact strip with one bar per interval, for "calls per minute" next to a number, not a chart to read values from. series is a list (or a Signal, Computed or function returning one) with a number per bar, or a tuple of numbers for a stacked bar, first segment at the bottom:

from indah import BarStrip, Signal

calls = Signal([(3, 1), (5, 0), (2, 2)])  # (hits, misses) per minute
BarStrip(
    calls,
    tones=["ok", "err"],  # theme tones: neutral, ok, warn, err, info
    names=["hit", "miss"],
    labels=lambda: [f"{m}m ago" for m in range(len(calls.value), 0, -1)],
    label="Calls per minute",  # the name a screen reader hears
    height=40,  # pixels, 8 to 240 (default 32)
)

The strip updates in place when the source changes, so appending a bar from ctx.every or a Stream callback is cheap; only the latest 1440 bars are sent. The scale is the tallest bar unless you fix it with max=. Negative, non-finite or non-numeric values draw as zero. Tones are the theme's colours, so they follow light and dark and Theme(...). The default tone is info, and a segment with no tone in the list is neutral.

Colour is never the only cue. Hover a bar, or focus the strip (it is one tab stop) and use the arrow keys, Home and End, and a tooltip reads 12:03: 4 (hit 3, miss 1); a screen reader gets the same text from a live region. Escape hides the tooltip and Tab moves on, so the keyboard is never trapped. Give stacked bars names= and the bars labels= so the tooltip says what each part is. Animation is off when the reader asks for reduced motion.

DataFrame duck-types its input: a pandas frame (via to_dict(orient="split")), a {columns, rows} dict, or a list of record dicts. Neither pandas nor Matplotlib is a hard dependency; each is only touched if you pass it.

Plot takes anything with a savefig method (a Matplotlib figure) and serialises to an image, so the browser needs nothing extra to show it.

A PNG does not know whether the page is light or dark, so a figure with the default white background shows as a light box on a dark page. Draw it on a transparent background with mid-grey text, spines and ticks, which read on both: fig.patch.set_alpha(0), ax.set_facecolor("none") and a grey such as #8d8691 for the ink (the gallery's charts and classifier do exactly this).

Inputs

Inputs are two-way bound: the UI reflects the signal, and a user change writes back to the signal.

from indah import Slider, TextInput, Select, Signal, Column

n = Signal(3)
who = Signal("world")
choice = Signal("squares")

page = Column(
    children=[
        Slider(n, min=0, max=10, step=1, label="n"),
        TextInput(who, placeholder="your name", label="Name"),
        Select(choice, options=[("squares", "Squares"), ("primes", "Primes")], label="Dataset"),
    ]
)

Select options are (value, label) pairs, or plain strings when the value and label are the same. Read the current value anywhere with .value; it stays in sync with what the user picked.

Options may change while the page is open. Pass a Signal, a computed or a function instead of a list (to Select, Radio, MultiSelect, Chips or Segmented) and the control follows it with no reload. A selection whose value is still offered is kept. A multi-value control (MultiSelect, Chips(multi=True)) drops values that are no longer offered; a single-value control leaves its signal alone, so your code decides what a removed project should fall back to.

projects = Signal(["alpha", "beta"])
current = Signal("beta")
Select(current, options=projects, label="Project")
projects.set([*projects.value, "gamma"])  # "gamma" appears; "beta" stays selected

Chips (filter chips, single or multi=True) and Segmented (a joined row, one selected) take the same options plus an optional count and tone per option: {"value": "err", "label": "Errors", "count": 2, "tone": "err"}, or Segmented(mode, options=[...], tones={"record": "err"}).

Try them here. The example runs in your browser; nothing is downloaded until you press Run it.

Chips and segmented controls - Filter chips (single or multi, with counts and tones) and a segmented control; arrow keys work.
def choices(ctx: indah.Context) -> indah.Component:
    level = indah.Signal("all")
    mode = indah.Signal("replay")
    kinds = indah.Signal(["tool"])
    return indah.Column(
        gap="1rem",
        children=[
            indah.Chips(
                level,
                label="Level",
                options=[
                    "all",
                    {"value": "warn", "label": "Warnings", "count": 4, "tone": "warn"},
                    {"value": "err", "label": "Errors", "count": 2, "tone": "err"},
                ],
            ),
            indah.Chips(
                kinds,
                multi=True,
                label="Kinds",
                options=[("tool", "Tool calls"), ("chat", "Chat"), ("embed", "Embeddings")],
            ),
            indah.Segmented(
                mode,
                label="Mode",
                options=[("replay", "Replay"), ("record", "Record")],
                tones={"record": "err"},
            ),
            indah.Text(
                lambda: (
                    f"Level {level.value}, kinds {', '.join(kinds.value) or 'none'}, {mode.value}."
                ),
            ),
        ],
    )

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

TextInput(value, password=True) masks the field (an <input type="password">) for secrets like an API key. It is only display masking - the value still round-trips like any input, so keep it in per-session state and never log it.

Forms

indah.Form groups inputs with a validator per field, a valid flag, an error beside each field and a submit button that is disabled while the form is invalid. The steps are in How to validate a form. Without a Form, TextInput(..., error=), Select(..., error=) and Button(..., disabled=) take a message or a flag, or a function of signals.

Buttons and async handlers

from indah import Button, Signal, Text, Column

count = Signal(0)


def bump():
    count.set(count.value + 1)


page = Column(
    children=[
        Button("Add one", on_click=bump),
        Text(lambda: f"count = {count.value}"),
    ]
)

An on_click handler may be async def. It runs in the background, so a handler that awaits (an async HTTP call, asyncio.sleep) never holds up other viewers, and the rest of the UI stays responsive while it runs.

A plain def handler runs on the event loop, and so does any blocking call inside an async def one. While it runs, every tab on that server waits. Move blocking work (a database query, requests, a model forward pass) off the loop with await asyncio.to_thread(...); see Slow handlers.

Streaming

StreamText holds an accumulating string. feed(token) appends a delta over SSE - only the new text crosses the wire, not the whole string - and reset() clears it.

from indah import StreamText

out = StreamText(label="Response")
out.feed("Hello ")
out.feed("world")  # the shell appends, token by token
out.reset()  # start over

Pair it with an async button handler (see Quickstart) to stream a model's output into the page.

Beyond the starter set

If the set does not cover what you need, register a custom component against the public protocol - no framework fork, no Node build. See Custom components.