UI element model
How the UI layer works as a whole: elements are plain objects built by global factory functions, composed through children arrays, configured by chaining, and updated by mutating properties — no JSX, no virtual DOM, no re-render cycle. Layout is flexbox (Yoga) in logical px, screen space Y-down. This page covers the model; the rest of the section covers the elements:
- Styling —
.style(), units, safe areas,animateTo - Containers —
UIRow,UIColumn,UIScrollable,UISpacer - Content elements —
UIText,UIImage,UIVideo - Interactive elements —
UIButton,UIInput,UITextArea - Screens & navigation —
UIScreen,Router - Widgets —
UIWidgetoverlays and modals - Virtualized lists —
UIVirtualizedList - Fonts —
registerFont
At a glance
let counter = 0
let label: UIText
const screen = UIScreen([
label = UIText("Taps: 0").style({ color: "white", fontSize: 24, fontWeight: 700 }),
counter > 0 ? UIText("already tapped") : null, // null children are skipped
UIButton([ UIText("Tap").style({ color: "white" }) ])
.style({ bgColor: "#FF4032", borderRadius: 12, p: 16, alignSelf: "flex-start" })
.onClick(() => { label.text = `Taps: ${++counter}` }), // mutate content directly
]).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })
screen.open()Factories, not constructors
Every element is created by calling a global function — never new:
UIColumn(children?) // containers: an optional children array
UIText(text) // content elements: the content itself
UIImage(src)The factory takes only the element's content; everything else is configured by chaining —
.style(), .onClick(), .append(), .animateTo() — each returns the element itself, so
construction reads as one chain.
every factory also accepts a style object as an optional first argument
(UIText({ fontSize: 20 }, "Hi")). It still works and appears in older projects, but new code
sets styles through .style().
Elements carry a readonly type string ("column", "text", …) identifying what they are.
Children arrays & conditional rendering
Containers take a plain array. A null, undefined, or false entry is skipped entirely —
no element, no layout slot — which is the conditional-rendering idiom:
UIColumn([
header,
isLoading ? spinner : null, // ternary with null
showFooter && footer, // && short-circuit
])// ✗ empty container as a placeholder (web habit) — it still occupies a flex slot
UIRow([ isGroup ? button : UIColumn([]) ])
// ✓ null is skipped — no phantom element
UIRow([ isGroup ? button : null ])Capturing references
To keep a handle on a nested element, use an assignment expression inside the array — it both sets the variable and adds the element:
let label: UIText
let input: UIInput
UIColumn([
label = UIText("Hello"),
input = UIInput(),
])
label.text = "Updated" // later
console.log(input.value)let is a statement, not an expression — declare outside, assign inside:
UIRow([ let input = UIInput() ]) // ✗ syntax errorPrefer captured refs over indexing container.children — children entries are untyped
(UINodeChild), so reading them back requires a cast.
Content properties vs styles
Mutable content lives directly on the element, not in the style. Setting it re-renders immediately, mounted or not:
text.text = "Updated" // UIText
input.value = "" // UIInput / UITextArea
image.src = newUrl // UIImage
video.player // UIVideo — read-only link to its VideoPlayerStyles carry appearance and layout only. In particular, never put an image's content in
bgImage — that's a container background. See content.md.
Updating children imperatively
Containers (UIRow, UIColumn, UIScrollable, …) expose direct child manipulation — there is
no diffing; you state the change:
list.setContent(items.map(Row)) // replace all children
list.append(row1, row2) // add at the end
list.insert(0, banner) // add at an index (into .children)
list.remove(row1) // remove by identity
list.children // the current array (readonly)All four work both before and after the element is on screen. For long or unbounded data, use
UIVirtualizedList instead of setContent over a big array.
Reading measured size — onLayout
Sizes exist only after layout. There is no synchronous getter (no el.width, no
getBoundingClientRect) — onLayout is the only way to read an element's box:
el.onLayout(({ left, top, width, height }) => { ... }) // logical px; top/left relative to the parentIt fires when the element first gets a layout and again whenever its box changes (resize, content change). Multiple callbacks can be registered; each call chains.
Reusable components
A "component" is just a factory function returning an element — chain onto the result like any
other element. Shared styles are plain objects typed with the global Style<T> helper (a type
only — nothing to import or instantiate):
const heading: Style<UIText> = { color: "white", fontSize: 24, fontWeight: 700 }
const Card = (title: string, subtitle: string) => UIButton([
UIText(title).style({ fontWeight: 700, color: "white" }),
UIText(subtitle).style({ color: "#888", fontSize: 13 }),
]).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start" })
Card("Title", "Subtitle").style({ bgColor: "#111" }).onClick(() => { ... })every element accepts a name string in its style object — a semantic label, not a
style. It's extracted at construction and surfaced on the node (el.name) as a stable selector
for tests and review tooling.
See also
- Styling — the
.style()model this page assumes - Pointer events & gestures —
onClick/onTouchStartevent objects - Conventions — logical px, chaining, lifecycle rules