The widget catalogue
There is no widget primitive. The protocol can express sixteen node kinds and nothing else — a button is a box with a click handler and a text child. Everything below is composed from those kinds by ordinary Soli functions that return hashes, which is why adding a widget is a library change and never a new client.
Where these live. The catalogue is not yet shipped as a Soli library: it is a single file, app/controllers/eui_builders.sl, in the counter-app example of the eui repository. Copy it into your application and edit it — that is the intended use, and it is why a widget you disagree with is yours to change.
Every preview below is an approximation drawn in HTML, because the real thing is painted on the GPU by a native client and cannot render in a browser. Treat them as a sketch of the shape, not of the pixels.
Layout and containers
Every container takes a style hash and a list of children. column, row and stack are the same box primitive with a different display.
column(style, children)
A box that stacks children vertically. The workhorse.
column({"gap": 3, "pad": 4}, [
text("First", {}),
text("Second", {})
])
row(style, children)
Horizontal flow. Pair with spacer() to push the tail to the right.
row({"gap": 2, "align": "center"}, [
text("Label", {}),
spacer(),
button("Go", "go")
])
stack(style, children)
Children painted on top of one another, in order. The base for badges on avatars and overlay content.
stack({}, [
avatar(src, 40),
badge("3", "danger")
])
card(style, children)
A raised surface: padding, radius and a border from the theme roles.
card({}, [
h2("Billing"),
muted("Next invoice on the 1st")
])
scroll(style, children)
A clipping viewport. Children lay out fully; only the visible part is painted.
scroll({"height": 200}, rows)
list(style, item_height, children)
A scroll that virtualises: with a fixed item_height only the visible window is laid out. Use it past a few hundred rows.
list({"height": 400}, 32, rows)
list_window(style, item_height, count, heights, children, on_window)
Virtualisation where the server holds the data: the client reports the visible range through on_window and you send only those rows. heights allows variable row heights.
list_window({"height": 400}, 32, total, heights,
visible_rows, "window")
tile(basis, node)
Wraps a node with a flex basis, for grid-like rows that wrap.
row({"gap": 3, "wrap": true}, cards.map(fn(c) tile(240, c)))
labelled(title, child)
A caption above any node. The building block behind field.
labelled("Region", select(regions, value, open, "t", "pick"))
spacer()
Flexible empty space. Inert: no text, props or handlers.
row({}, [text("Left", {}), spacer(), text("Right", {})])
divider()
A hairline rule. Inert.
column({"gap": 3}, [h2("Members"), divider(), rows])
keyed(key, node)
Gives a node a stable identity so the diff moves it instead of rebuilding it. Required on any list whose rows reorder.
items.map(fn(i) keyed(i["id"], row_for(i)))
pane_px(name)
No description in the source.
px >= pane_px(name)
Real use — eui_builders.sl (inside `pane_min`):1128
pane_bp(px)
No description in the source.
badge(pane_bp(px), tone)
Real use — app/controllers/live_controller.sl:864
pane_min(px, name)
No description in the source.
roomy = pane_min(px, "md")
Real use — app/controllers/live_controller.sl:860
split_span(extent, bar)
The room the two panels share, once the divider has taken its own.
span = split_span(extent, bar)
Real use — eui_builders.sl (inside `split_sizes`):1144
split_sizes(extent, fraction, min_a, min_b, bar)
`fraction` is per mille — an integer, so it survives a round trip through state and a local handler's props without ever being a float. Both conversions round rather than truncate, which is what makes the trip exact: a divider dropped at a pixel and rebuilt from its fraction lands on the same pixel,
sizes = split_sizes(extent, fraction, min_a, min_b, bar)
Real use — eui_builders.sl (inside `split_pane`):1244
split_at(extent, at, min_a, min_b, bar)
The pointer's position along the axis becomes the fraction the divider sits at. Clamped to both minimums, so a drag that runs past a panel's floor stops there rather than inverting the pair — and the clamp lives here, once, instead of in every application that draws a split.
state[name] = split_at(extent, at, min_a, min_b, bar)
Real use — eui_builders.sl (inside `split_drag`):1288
split_panel(build, px, across: Bool, cross)
No description in the source.
split_panel(o["a"], sizes[0], across, cross),
Real use — eui_builders.sl (inside `split_pane`):1258
split_divider(key, across: Bool, bar, cross, fraction, on_drag, dragging: Bool, label)
The divider is not a `control`: it is a separator, its press has to reach the server as well as restyle locally, and `control` would put its own `pointer_down` over the top of that. It is keyed so the local chunk can name it, and it holds `key_down`, which is what puts it in the Tab order — so a spl
split_divider(key + ":bar", across, bar, cross, fraction, on_drag, o["dragging"] == true, o["label"]),
Real use — eui_builders.sl (inside `split_pane`):1259
split_pane(o)
key required; the divider is keyed from it dir "row" | "column" size the container's extent along the split axis, in px cross the extent across it; "100%" if absent fraction per mille, 0..1000 min_a / min_b the smallest each panel may become, in px bar the divider's thi
split_pane({
Real use — app/controllers/live_controller.sl:825
split_drag(state, params, name, dir, extent, min_a, min_b, bar)
The four events a split sends, folded into a component's state. `name` is the state key holding the fraction; `name + "_drag"` holds whether a drag is in flight. An application writes one line in its handler and is done. One `pointer_move` while the divider is held: the point along the axis is where
state = split_drag(state, params, name, dir, extent, min_a, min_b, bar)
Real use — eui_builders.sl (inside `split_event`):1319
split_keys(state, params, name, dir, step)
The keyboard's half of the same divider: an arrow moves it by a step, Home puts it back in the middle, and the result is held inside the thousandths the fraction is measured in.
state = split_keys(state, params, name, dir, step)
Real use — eui_builders.sl (inside `split_event`):1321
split_event(state, params, name, dir, extent, min_a, min_b, bar)
No description in the source.
"split_x" => split_event(state, params, "split_x", "row", gallery_split_extent(state), 220, 260, 6),
Real use — app/controllers/live_controller.sl:2500
table_row(key, values, widths)
No description in the source.
table_row(i, [
Real use — app/controllers/live_controller.sl:274
Overlays and structure
Anything that floats uses the overlay primitive, which paints after its siblings. Open and closed is your state — the client holds none of it.
dialog(title, body_children, actions)
A modal over a scrim. actions is a list of buttons.
dialog("Delete project?",
[muted("This cannot be undone.")],
[secondary_button("Cancel", "close"),
danger_button("Delete", "destroy")])
sheet(side, children)
A panel anchored to an edge — "left", "right", "bottom".
sheet("right", [h2("Filters"), filter_controls])
drawer(children)
A left sheet, the navigation case, at one argument.
drawer(sidebar(links, active, "go"))
popover(anchor, content, open)
A panel positioned against a node rather than the viewport.
popover(button("Share", "toggle"), share_panel, s["open"])
menu(items, on_pick)
A list of choices in an overlay. Each item carries its own id back.
menu([{"id": "dup", "label": "Duplicate"},
{"id": "del", "label": "Delete"}], "pick")
tabs(names, active, on_select)
A row of labels with the active one underlined.
tabs(["Overview", "Usage", "Billing"], s["tab"], "select_tab")
accordion(sections, open_id, on_toggle)
Sections that expand one at a time.
accordion([{"id": "a", "title": "General", "body": general}],
s["open"], "toggle_section")
stepper(steps, current)
Progress through a sequence. Display only.
stepper(["Account", "Plan", "Payment"], 1)
toolbar(children)
A dense row on a raised surface, for actions above a table or canvas.
toolbar([icon_button("+", "add", {}), icon_button("⟳", "refresh", {}),
spacer(), select_sized(views, v, o, "t", "p", 120, false)])
Navigation
Navigation is application state, not a URL: a click is an event and your handler decides what the next tree is.
navbar(brand, links, active, on_go)
A top bar with a brand and a link row.
navbar("Acme", ["Home", "Docs", "Pricing"], s["page"], "go")
sidebar(links, active, on_go)
A vertical nav column with the active entry highlighted.
sidebar(["Inbox", "Sent", "Drafts"], s["box"], "go")
breadcrumb(crumbs, on_go)
A trail. The last entry is the current place and is not a link.
breadcrumb([{"id": "root", "label": "Projects"},
{"id": "p1", "label": "Acme"}], "go")
pagination(page, pages, on_page)
Previous, page numbers, next. Emits the page it wants.
pagination(s["page"], total_pages, "go_page")
segmented(options, selected, on_select)
Mutually exclusive options in one control. Fewer than five, or use select.
segmented(["Day", "Week", "Month"], s["range"], "set_range")
tree_view(nodes, open_ids, on_toggle, depth)
A nested, collapsible tree. Recursive: it calls itself at depth + 1.
tree_view(files, s["open"], "toggle_node", 0)
Theme and responsiveness
The client resolves roles against light or dark mode, density and font scale. The server never learns which.
theme_toggle()
Flips the client's mode locally — theme.toggle() in a local handler, so no round trip and no server state.
row({}, [spacer(), theme_toggle()])
bp(width) · bp_min(width, name) · bp_px(name)
Breakpoint helpers. The viewport reaches the handler, so the tree you build is the responsive answer — there is no media query.
cols = bp(params["viewport"]["w"]) == "sm" ? 1 : 3
with_state(state, root)
Attaches the state a local handler may read and write, at the root of the tree.
with_state({"count": s["count"]}, column({}, children))