Skip to content

How to author Vox views (VUV)

Vox UI is built with ordinary function calls. There is no JSX, no class strings, and no separate styling DSL — every piece of a view is a typed Vox expression.

This guide is the practical companion to gui-authoring-syntax-2026.md, which is the design rationale.

  1. A view is an expression. Built with function calls. Same syntax inside a component, in a top-level let, returned from a function.
  2. A trailing { … } block is the call’s children. Each statement-position expression in the block is one child.
  3. Style is typed named arguments, not class strings. The compiler emits Tailwind/CSS.
// vox:skip
component Greeting(name: str) {
view: column(pad=4, gap=2) {
text(size="lg", weight="bold") { "Hello, " }
text() { name }
}
}

What’s happening:

  • column(pad=4, gap=2) is a primitive. It lowers to <div class="flex flex-col p-4 gap-2">.
  • Children inside the { … } block: two text elements.
  • text(size="lg", weight="bold") adds text-lg font-bold to the emitted class list.
  • name (a bare identifier) is one child — the runtime value of the parameter.

A view-call without children doesn’t need an empty block. Three trigger forms all sugar to a self-closing element:

// vox:skip
ComposerPanel() // capitalized — always a view-call
panel(w=2, h=2, bg="zinc.600") // recognized primitive — always a view-call
input(attr_type="checkbox") // lowercase + named-only args — view-call
my_func() // bare lowercase, no args — regular function call
Some(x) // capitalized + positional arg — enum constructor (regular call)

These are recognized as UI primitives by the lowering layer. They emit a fixed HTML tag plus a base Tailwind class list, then accept your typed kwargs.

PrimitiveHTMLNotes
stack, column<div>flex, flex-col
row<div>flex, flex-row
wrap<div>flex, flex-wrap
text<p>accepts size, weight
heading<h1><h6>accepts level (1–6), size, weight
link<a>underline-on-hover styling
image<img>src and alt pass through as HTML attrs
button<button>accepts `variant: “default"
panel, card<div>accept surface for token pairs
list, list_item<ul> / <li>
route_outlet<div>
overlay, toast, drawer, modal<div>accept position, z

For anything not in the primitive set (raw HTML elements like input, select, textarea), the lowercase + named-args rule kicks in and the tag passes through verbatim.

Any primitive accepts these kwargs. Values resolve to Tailwind classes via a typed table; conflicts with primitive defaults on the same axis are auto-suppressed.

Kwarg(s)Tailwind
pad, pad_x, pad_y, pad_t, pad_b, pad_l, pad_rp-N, px-N, …
mb, mt, ml, mr, mx, mymb-N, …
w, h, min_w, min_h, max_w, max_hw-N, max-w-N, …
bg, colorbg-…, text-… (token-shaped values like zinc.400 auto-dash to zinc-400)
border, border_x/y/t/b/l/r, border_colorborder, border-x-N, border-{color}
radius, radius_t/b/l/r/tl/tr/bl/brrounded, rounded-tl-N, …
overflow, overflow_x, overflow_yoverflow-…
flex, shrink, growflex-1, shrink-0, …
justify, items, gap, gap_x, gap_yjustify-X, items-X, gap-N
tracking, leading, case, italic, font_familytracking-X, leading-X, uppercase/lowercase, italic/not-italic, font-mono/sans/serif
position, inset, top, bottom, left, rightabsolute/relative/fixed, top-N, …
shadow, opacityshadow, shadow-md, opacity-50
raw_class(escape hatch — value passes through verbatim)

Style kwargs can take if/else expressions. The compiler lowers them to runtime ternaries inside className:

// vox:skip
component Bubble(role: str) {
view: panel(
bg=if role is "user" { "blue.600/20" } else { "white/5" },
radius_br=if role is "user" { "sm" } else { "2xl" }
) {
text() { role }
}
}

Lowers to (effectively):

<div className={[
(role === "user" ? "bg-blue-600/20" : "bg-white/5"),
(role === "user" ? "rounded-br-sm" : "rounded-br-2xl"),
/* primitive base classes */
].filter(Boolean).join(" ")}>
<p>{role}</p>
</div>

Other expression shapes (function calls, complex computations) on a typed kwarg fall back to passthrough — fix the source so the value is a literal or if/else.

Event kwargs use snake_case. The compiler renames to React-style camelCase at emit:

// vox:skip
button(on_click={count = count + 1}) { "Increment" }
input(on_change={fn(e) handle(e)}, attr_type="text")

Supported events: on_click, on_change, on_input, on_submit, on_keydown, on_keyup, on_mouseenter, on_mouseleave. Add more in crates/vox-codegen/src/codegen_ts/hir_emit/compat.rs.

HTML attributes whose names collide with Vox keywords (type, for, …) use the attr_ prefix:

// vox:skip
input(attr_type="checkbox", checked=t.done)
label(attr_for="email") { "Email" }

The parser strips attr_ so the emitted HTML uses the bare attribute name.

Each statement-position expression inside a trailing block is one child. Expressions can be:

  • String literals"Hello".
  • Bare identifiers / field accessname, t.title.
  • Other view calls — nested.
  • if/else expressions returning views — branches must be view-shaped.
  • match expressions returning views — each arm is a view.
  • tasks.map(fn(t) { … }) — list comprehensions.
// vox:skip
column() {
if logged_in {
Dashboard(user=current_user)
} else {
LoginForm()
}
tasks.map(fn(t) {
TaskRow(task=t)
})
}

Capitalized callees + named-only args sugar to JSX self-closing. To call an existing React component, just call it:

// vox:skip
ComposerPanel()
DataChart(data=[1, 2, 3])
ChatMessage(role="user", content=msg) {
// children inside another view-call's block work too
text() { "Reply" }
}

When typed kwargs don’t cover what you need:

  • raw_class="…" — Tailwind utilities verbatim. Use sparingly; everything in raw_class is invisible to the compiler.
  • Unknown kwargs — pass through as raw HTML attributes (after kwarg-to-className resolution finds nothing).
  • Angle-bracket JSX (<row>...</row>) — retired. < in expression position is a parse error.
  • Class strings as the styling primitive — replaced by typed kwargs.
  • Positional args in view calls — view calls are keyword-only by design.
  • HTML attributes whose name is a Vox keyword without the attr_ prefix.
// vox:skip
column() {
if items.is_empty() {
text(color="zinc.500") { "Nothing here yet." }
} else {
list() {
items.map(fn(i) { ItemRow(item=i) })
}
}
}
// vox:skip
button(
raw_class="tab-btn",
bg=if active_tab is "speak" { "blue.600" } else { "transparent" },
on_click={active_tab = "speak"}
) { "LOQUELA" }
// vox:skip
panel(surface=if mine { "chat.user" } else { "chat.assistant" }) {
text() { content }
}

The surface kwarg pulls a registered foreground/background pair from the token registry; the validator then checks WCAG contrast at compile time.