A hundred and seven functions, and the vocabulary they speak.
An EUI component is a Soli function that returns a hash. There is no widget runtime, no component registry and no client release behind any of the names below — each one composes primitives, so an application can add its own without asking anyone.
Everything is one hash
A view returns this shape; the server converts it to nodes, diffs it against the tree this session already has, and encodes the patch. Eight keys, all optional but the kind.
{
"k": "box",
"key": "post:412",
"s": {"display": "row", "gap": 3,
"bg": "surface.raised"},
"p": {"id": 412},
"on": {"click": "like"},
"c": [ … ],
"t": "Ada Lovelace",
"intern": true
}
- k
- The kind. One of sixteen, and the set is closed
- key
- Identity. Keyed children are matched by key and moved, not rebuilt
- s
- Style, resolved once per session and then referenced by id
- p
- Props. They come back to the handler as the props of the node that fired
- on
- Handlers, by event name
- c
- Children
- t
- Text content, for
textandinput - intern
- Put this text in the atom table instead of inline
The sixteen kinds
Implemented in the client. Adding one is a protocol version bump, which is a deliberately high price: it is what keeps the catalogue a library rather than a client release.
Thirty-seven keys, and no cascade
A style is a flat hash. There is nothing to select against, no inheritance to compute, and no specificity to lose an afternoon to. If two places should look alike, they call the same function. A key the server does not know is an error, not a shrug: the render fails, and says which key.
| Key | Values |
|---|---|
| display | row · column · stack · grid · none |
| wrap | nowrap · wrap · wrap_reverse |
| justify | start · center · end · between · around · evenly |
| align | start · center · end · stretch · baseline |
| self | as align, plus auto — this node's own cross-axis placement |
| grow · shrink | 0–255 |
| gap | a space index, 0–255 |
| width · height · min_width · min_height · max_width · max_height · basis | a length |
| pad · margin · border | edges |
| bg · fg · border_color | a colour |
| radius · shadow · opacity · z | 0–255 |
| font | sans · mono |
| size | a text-scale index |
| weight | regular · medium · semibold · bold |
| text_align | start · center · end · justify |
| clamp | maximum lines, then an ellipsis |
| underline · strike | true · false |
| overflow | visible · clip · scroll |
| transition | none · fast · base · slow |
| animation | none · spin |
| position | flow · absolute |
| cursor | default · pointer · text · grab · grabbing · resize_h · resize_v · wait · not_allowed |
120 pixels, "auto", "50%",
"1fr", or "sp:4" for a space-scale index.
2 for all four, [y, x], or
[t, r, b, l]. They are space indices, not pixels, so the
viewer's density setting scales them.
A role, a literal "#RRGGBB", or "none".
A literal is right for a brand mark or a chart series, and wrong for
a surface.
The twenty-eight roles
The server never sends a colour. It sends a role, and the client resolves it against the viewer's mode — which is why the same view is correct in dark mode without the server ever learning the viewer went there, and why switching costs zero bytes.
What comes back
A handler named as a string is a server event: the client sends it, the handler runs, the view re-renders, the diff comes back. The handler is given the event, the state, and the props of the node that fired — which is how one handler serves ten thousand rows.
# The node carries the identity the handler will need. checkbox(item["title"], item["done"], "toggle", {"id": item["id"]}) # The handler reads it back. def todo(event_data) id = event_data["params"]["props"]["id"] … end
{"local": "self.style = @hover",
"styles": {"hover": hover},
"then": "like"}
Compiled to bytecode, verified by the client before its first
run, metered by fuel. styles declares the records
the chunk may point a node at, so hover and press switch between
styles the session already holds. then sends a
server event afterwards. Nothing a local handler does is trusted
— authorisation is never local.
All 107
They live in one file —
examples/counter-app/app/controllers/eui_builders.sl —
and you are meant to copy it and change it. A widget is not a protocol
feature, and none of these had to ask permission to exist. Each one
below carries a call you can paste into a view.
Every entry carries a drawing of what it makes, next to a call you can paste into a view. The drawings are HTML: this page is a document and cannot run a client, so the shapes are the widget's and the mechanism is not. A function that returns a value rather than a tree shows the value.
Primitives and wrappers 22
The whole vocabulary the protocol can express, plus the helpers every view uses.
- node(kind, style, children)node("box", {"gap": 2}, [text("Total", {})])
- The bare hash. Everything else on this page is built on it→ {"k": "box", "s": {"gap": 2}, "c": [ … ]}
- column(style, children)column({"gap": 4, "pad": 6}, [header, body])
- A box, laid out down
- row(style, children)row({"gap": 2, "align": "center"}, [mark, label])
- A box, laid out across
- stack(style, children)stack({}, [backdrop, caption])
- A box whose children are superimposed
- text(content, style)text("Total", {"size": 4, "weight": "semibold"})
- A run of textTotal
- spacer()row({}, [label, spacer(), button("Save", "save")])
- Empty space with grow: 1Save
- divider()column({"gap": 3}, [header, divider(), body])
- A hairline rule
- scroll(style, children)scroll({"height": 400}, rows)
- A clipping viewport
- list(style, item_height, children)list({"height": 400}, 22, rows)
- A virtualised list: only the visible rows are laid out
- list_window(style, item_height, count, heights, children, on_window)list_window({"grow": 1}, 128, count, heights, cards, "window")
- A windowed list — the server sends only what is in view
- input(value, on_change)input(state["typed"], "typed")
- A bordered single-line fieldAda
- button(label, on_click)button("Save", "save")
- The primary button: accent roles, local hover and pressSave
- image(src, width, height)image("public/images/chart.png", 320, 200)
- A picture from a file in the application, sent as a hash
- avatar(src, size)avatar("public/images/avatar.png", 32)
- A round picture
- canvas(width, height, paths)canvas(320, 120, [[3, "accent.base", 40, 60, 6]])
- A drawing surface: polylines, rectangles, areas, circles, arcs
- audio(src, props, on)audio("public/sounds/chime.wav", {"playing": true, "volume": 80}, {"ended": "sound_ended"})
- A sound. Draws nothing▶chime.wav · 1.6 s
- video(src, props, style, on)video("public/video/pulse.gif", {"playing": on}, {"width": "100%"}, {"ended": "video_ended"})
- A moving picture. GIF and animated WebP▶ 0:00 / 1:26
- keyed(key, n)keyed(invoice["id"], row({"gap": 2}, cells))
- Gives a node its identity for the diff, and returns it→ {"key": "412", "k": "box", … }
- with_state(state, root)with_state({"count": count}, column({"pad": 6}, children))
- Puts the state in the root's props, where local handlers read it→ {"p": {"count": 3}, "k": "box", … }
- bp(width)bp(state["viewport"]["width"])
- Breakpoint name for a viewport width — xs sm md lg xl 2xl, Tailwind rungs→ "md"
- bp_min(width, name)bp_min(w, "md")
- true when width is at least that breakpoint→ true
- bp_px(name)bp_px("md")
- The pixel floor→ 768
Text 4
Four functions, because a heading is a size and a weight, not a tag.
- h1(content)h1("Todo")
- Size 5, boldTodo
- h2(content)h2("Invoices")
- Size 4, semiboldInvoices
- muted(content)muted(remaining.to_s + " left")
- Size 1, text.muted3 left
- text_interned(content, style)text_interned(post["handle"], {"fg": "text.muted", "size": 1})
- Text put in the session's atom table — for short strings that repeat@adaone atom, sent once
Buttons 8
One engine, and the variants are arguments to it.
- button_variant(label, on_click, bg, fg)button_variant("Publish", "publish", "accent.base", "accent.on")
- The engine: a keyed box whose hover and press repoint it at declared styles, with no round tripPublish
- secondary_button(label, on_click)secondary_button("Clear done", "clear_done")
- surface.sunken on text.defaultClear done
- danger_button(label, on_click)danger_button("Delete", "destroy")
- danger.base on danger.onDelete
- ghost_button(label, on_click)ghost_button("×", "remove")
- No fill, accent text×
- icon_button(label, on_click, props)icon_button("›", "nav", {"delta": 1})
- A 28×28 square carrying props back to the handler›
- loading_button(label, on_click, key)loading_button("Load 5 000 more", "more", "more")
- Reveals its spinner and changes its label locally, then sends the eventLoading…
- local_button(label, program, after)local_button("+", "state.count += 1; value.text = str(state.count)", "increment")
- A button whose click runs a bytecode chunk first, then a server event+no round trip
- theme_toggle()theme_toggle()
- Light and dark, decided on the client; the server is never told☀/☾
Input and forms 9
None of them holds state. The handler does.
- checkbox(label, checked, on_toggle, props)checkbox(it["title"], it["done"], "toggle", {"id": it["id"]})
- The mark's fill says its state; props say which item it wasWrite the spec
- switch(label, on, on_toggle, props)switch("Notify me", state["notify"], "toggle_notify", {})
- A track and a knob, placed by justifyNotify me
- field(label, value, on_change)field("Name", state["name"], "name_changed")
- A muted label over an inputNameAda
- form(children, submit_label, on_submit)form([field("Name", "", "noop")], "Save", "save")
- The children, then a right-aligned submitAdaSave
- sized_input(value, on_change, width)sized_input(state["time"], "time_changed", 80)
- An input of a fixed width09:30
- select(options, value, open, on_toggle, on_pick)select(["EUR", "GBP"], state["ccy"], state["open"], "toggle_ccy", "pick_ccy")
- Closed, it is its anchor; open, a dropdown. The server owns openEUR ▾
- select_option(label, selected, on_pick)select_option("EUR", value == "EUR", "pick_ccy")
- One row of that dropdownEUR
- dropdown(anchor, content, open)dropdown(anchor, options, state["open"])
- A panel under its anchor; the anchor alone when closedEUR ▾GBPUSD
- slider(value, min, max, on_set)slider(state["volume"], 0, 100, "set_volume")
- A 240 px track. Click sets from the pointer x, arrows nudge
Calendar and pickers 12
One engine, three pickers. Months are "YYYY-MM", days are ISO strings — which compare correctly as strings, so no date arithmetic reaches the view.
- calendar(month, selected, range_start, range_end, on_pick, on_nav)calendar(state["month"], [state["day"]], "", "", "pick", "nav")
- Navigation, weekday header, a seven-column grid
- date_picker(month, value, on_pick, on_nav)date_picker(state["month"], state["day"], "pick", "nav")
- One day2026-09-08
- datetime_picker(month, date, time, on_pick, on_nav, on_time)datetime_picker(month, date, time, "pick", "nav", "set_time")
- A day and an HH:MM field09:30
- date_range_picker(month, start, finish, on_pick, on_nav)date_range_picker(month, start, finish, "pick", "nav")
- Two ends on one calendar
- day_cell(iso, label, selected, in_range, on_pick)day_cell("2026-09-08", "8", true, false, "pick")
- One day, carrying its ISO date8
- day_blank()day_blank()
- The gap before the first of the month
- weekday_header()weekday_header()
- Mo to SuMo Tu We Th Fr Sa Su
- month_label(month)month_label("2026-09")
- The month, spelled for a person→ "September 2026"
- month_shift(month, delta)month_shift("2026-09", 1)
- The next or previous month→ "2026-10"
- weekday_index(day)weekday_index(DateTime.parse("2026-09-01"))
- Monday is 0→ 0
- two_digits(n)two_digits(7)
- A number padded to two digits→ "07"
- labelled(title, child)labelled("Due date", date_picker(month, day, "pick", "nav"))
- A titled card, so a picker reads as one thingDue date
Structure and overlays 12
- card(style, children)card({"gap": 2}, [h2("Tree"), body])
- Raised surface, subtle border, radius 3 — your style wins where it sets a keyTree
- tabs(names, active, on_select)tabs(["Overview", "Inputs", "Data"], tab, "tab")
- A row of labels, the active one underlinedOverviewInputsData
- dialog(title, body_children, actions)dialog("Delete this?", [text("It cannot be undone.", {})], [secondary_button("Cancel", "close"), danger_button("Delete", "destroy")])
- An overlay: dimmed ground, centred panel, actions rightDelete this?Delete
- sheet(side, children)sheet("right", [h2("Filters"), body])
- A 320 px panel at an edge, over a dimmed ground
- drawer(children)drawer([sidebar(links, active, "go")])
- sheet("left", …)
- popover(anchor, content, open)popover(button("Help", "toggle_help"), [tooltip("Ctrl+K")], state["help"])
- A panel over its anchorHelpCtrl+K
- toolbar(children)toolbar([ghost_button("Undo", "undo"), ghost_button("Redo", "redo")])
- A raised strip with a bottom rule
- accordion(sections, open_id, on_toggle)accordion(sections, state["open"], "toggle_section")
- Sections of id, title and body; the open one shows its body▾ DeliveryShips in two days▸ Returns
- stepper(steps, current)stepper(["Cart", "Address", "Payment"], 1)
- Numbered dots: done filled, current ringed
- menu(items, on_pick)menu(["Rename", "Duplicate", "Delete"], "pick")
- A raised column; each item carries its own valueRenameDuplicateDelete
- tooltip(content)tooltip("Ctrl+K")
- Inverted text on the default inkCtrl+K
- segmented(options, selected, on_select)segmented(["Day", "Week", "Month"], seg, "seg")
- One sunken row, the selected option raisedDayWeekMonth
Navigation 5
- navbar(brand, links, active, on_go)navbar("Needle", ["Home", "Search"], active, "go")
- A brand and links, each carrying its pathNeedleHomeSearch
- sidebar(links, active, on_go)sidebar(["Inbox", "Sent"], active, "go")
- A 200 px rail, the active entry filledInboxSentDrafts
- breadcrumb(crumbs, on_go)breadcrumb([{"label": "app", "path": "/"}, {"label": "invoices", "path": "/invoices"}], "go")
- Every crumb but the last is a linkapp / invoices / 412
- pagination(page, pages, on_page)pagination(page, 9, "page")
- ‹ and ›, each carrying the page it would reach‹3 / 9›
- tree_view(nodes, open_ids, on_toggle, depth)tree_view(tree, state["open"], "toggle", 0)
- Recursive, indented by depth▾ app▾ views· home
Data 15
- table_header(labels, widths)table_header(["Reference", "Client", "Amount"], widths)
- A header row of fixed column widthsReferenceClientAmount
- table_row(key, values, widths)table_row(inv["id"], [inv["ref"], inv["client"], inv["total"]], widths)
- A keyed body row — so re-sorting moves rows instead of rebuilding themINV-412Ada84,20
- data_grid(columns, rows, selected, editing, sort, on_select, on_sort, on_change, on_key)data_grid(columns, rows, selected, editing, sort, "select", "sort", "change", "key")
- Header outside the scroll, virtualised rows, a selected cell, an input in the one being edited. A sort moves keyed rows; a commit is set_textRefClientAmount ↑FA-1001Ada SARL137 €FA-1004Margaret SA112 €
- grid_header(columns, sort, on_sort)grid_header(columns, {"col": "amount", "dir": "asc"}, "sort")
- One labelled cell per column; the active sort is markedRefClientAmount ↑
- grid_row(record, columns, selected, editing, on_select, on_change, on_key)grid_row(row, columns, selected, {}, "select", "change", "key")
- A keyed row of grid_cellFA-1001Ada SARL137 €
- grid_cell(row_id, col, value, selected, editing, open, on_select, on_change, on_key)grid_cell(row["id"], col, row["status"], true, true, true, "select", "change", "key")
- A keyed box: text, an input, or a compact select when the column has optionsPaid ▾
- grid_col_editable(col)grid_col_editable({"id": "ref", "editable": false})
- false only when the column sets editable: false→ false
- grid_col_align(col)grid_col_align({"id": "amount", "align": "end"})
- start, center or end — default start→ "end"
- grid_sort_rows(rows, col, dir)grid_sort_rows(rows, "amount", "asc")
- sort_by that column, reversed when dir is "desc"→ [{id: "FA-1004", amount: "112 €"}, …]
- stat(label, value, hint)stat("Nodes", "14", "primitives")
- A card with one big numberNodes14primitives
- chip(label, on_remove, props)chip("invoices", "remove_filter", {"id": "invoices"})
- A pill with an optional ×invoices ×
- badge(label, tone)badge("10 000 rows", "info")
- A tone is a role family: "info" means info.subtle under info.base10 000 rows
- progress(fraction)progress(0.62)
- A bar; the fraction is clamped to 0–1
- skeleton(width, height)skeleton(320, 12)
- A sunken placeholder
- code_block(code)code_block(state["snippet"])
- Monospace on a sunken groundrouter_eui("counter", …)
Feedback 5
- toast(message, tone)toast("Saved", "success")
- A raised, toned stripSaved
- banner(message, tone, action_label, on_action)banner("Served by Soli, drawn by EUI.", "info", "Open sheet", "sheet")
- Full width, an accent edge, an action at the end
- spinner()spinner()
- An 18 px arc the client spins — no frames on the wire
- spinner_sized(size)spinner_sized(14)
- The same, sized
- empty_state(title, body, action_label, on_action)empty_state("No invoices yet", "They will appear here.", "New invoice", "new")
- A mark, a title, a line, one buttonNo invoices yetNew invoice
Charts 8
A chart is a canvas and a list of numbers. No chart library, no SVG, no client change.
- chart_line(values, w, h)chart_line([3, 7, 4, 9, 6], 320, 120)
- Grid, polyline, a dot per point
- chart_area(values, w, h)chart_area([3, 7, 4, 9, 6], 320, 120)
- Grid, filled area, line on top
- chart_bar(values, w, h)chart_bar([3, 7, 4, 9, 6], 320, 120)
- Grid and bars
- chart_donut(parts, w, h)chart_donut([5, 3, 2], 140, 140)
- One arc per part, in the four base roles
- chart_points(values, w, h)chart_points([3, 7, 4], 320, 120)
- The series scaled into w × h as [x, y] pairs→ [[4, 4], [82, 44], [160, 24]]
- chart_grid(w, h)chart_grid(320, 120)
- Four hairlines to read a series against→ [[0, "border.subtle", 1, 4, 4, 316, 4], … ]
- chart_max(values)chart_max([3, 7, 4])
- The top of the scale, never 0→ 7
- flatten_points(points)flatten_points([[0, 10], [8, 4]])
- [[x, y], …] into [x, y, …]→ [0, 10, 8, 4]
Media 3
The client seeks when position changes, which is how a scrubber works without a seek opcode.
- media_button(on, event, props)media_button(playing, "play", {"id": post["id"]})
- 28 px play and pause▶play
- media_scrubber(width, at, duration, on_seek, props)media_scrubber(300, at, duration, "seek", {"id": id, "w": 300})
- A fixed-width bar, so the click's x maps straight onto the position
- media_clock(ms)media_clock(67000)
- Milliseconds as minutes and seconds→ "1:07"
Feed 4
The pieces that make a hundred thousand cards work — virtualisation is a composition question, not a client feature.
- post_card(post, liked, play, height)post_card(post, liked.includes?(i), play, 128)
- One card of a fixed height, which is what lets the list virtualiseA♥ 12 ↩ 3
- post_media(post, play)post_media(post, {"sound": false, "video": true})
- The card's picture, moving picture, or sound with controls▶ 0:00 / 1:26
- post_action(glyph, count, on_click, props, active)post_action("♥", post["likes"], "like", {"id": post["id"]}, liked)
- One action under a post, carrying the post id♥ 12
- initial_avatar(letter, tone, size)initial_avatar("A", "accent.base", 40)
- A letter in a coloured discA
Six things worth knowing
State lives in the handler
No widget holds any. select is given open,
accordion is given open_id, tabs
is given active. That is why they are functions rather than
objects, and why any of them can appear twice on a page without a name
collision.
Props carry identity
A click arrives with the props of the node that fired, so one handler serves every row of a table. The client cannot invent them: the server keeps the tree it sent, and reads the props off its own copy.
Keys are for the diff
keyed(id, node) makes a child matched by key instead of by
position — the difference between one move and a rebuilt subtree.
Reversing fifty keyed rows costs 201 bytes on the wire.
data_grid is that contract as a widget: a sort moves rows,
a cell commit is set_text, and the header sits outside the
list because version 1 has no sticky.
Interning is for repetition
text_interned puts a short string in the session's atom
table, so the wire carries it once. That table is append-only, so a
unique value would be a permanent entry: intern a glyph or a handle,
never a cell value.
A window, not a list
list_window is given how many rows exist, every row's
height, and only the rows in view — each carrying its absolute
index. The client draws placeholders for what it has not received yet.
That is how a hundred thousand cards scroll on a budget of tens.
Responsive is a view branch
There are no sm: style prefixes. Store
params["viewport"] on connect and
viewport, then bp_min(width, "md")
chooses a different tree. The client never runs a media query.