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 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, 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(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)
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)
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)
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)
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)
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= 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.
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 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 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)
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)
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)
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)
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)
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)
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)
-).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)
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)
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)
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)
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)
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)
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)
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)
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.
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(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)
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)
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)
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)
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(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.
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.