UI element model
The starting page of the UI layer: a screen is built from elements made by global functions
(UIColumn(...), UIText("Hi")), children passed as arguments, styled with a chained .style(),
and changed by writing their properties or binding them to signals — no JSX, no re-render. Screens,
tabs, lists, styling and state each have a page of their own: the UI pages by task.
- build a screen with text and a button and show it
- lay out elements in rows and columns and style them (
.style()) - update what the screen shows when data changes
- open another screen and come back (
Router.push/Router.pop) - split the app into a theme file, screen files and
main.ts - build a list from an array, and show an element only when a condition holds
- add or remove a container's children from code (
append,remove) - keep a reference to a nested element to change it later
- measure an element's size and position on screen (
onLayout,getBoundingClientRect) - make a reusable component
At a glance
let taps = 0
let label: UIText
const details = UIScreen(
UIText("Details").style({ color: "white", fontSize: 22, fontWeight: 700 }),
UIButton(UIText("Back").style({ color: "white" })).named("back").onClick(() => Router.pop()),
).style({ bgColor: "black", px: "comfort-x", pt: "comfort-top", gap: 16 })
const home = UIScreen(
label = UIText("Taps: 0").style({ color: "white", fontSize: 24, fontWeight: 700 }),
UIRow(
UIButton(UIText("Tap").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
.named("tap")
.onClick(() => { label.text = `Taps: ${++taps}` }), // change content by writing it
UIButton(UIText("Details").style({ color: "white" }))
.style({ bgColor: "#333", borderRadius: 12, p: 16 })
.named("details")
.onClick(() => Router.push(details)), // the system back pops it too
).style({ gap: 12 }),
).style({ bgColor: "black", px: "comfort-x", pt: "comfort-top", gap: 16 })
Router.init(home)Layout is flexbox: a screen and a UIColumn stack their children top to bottom, a UIRow left to
right; sizes are logical px, y grows downward. Set every screen's bgColor and every text's
color — or set them once for the app with theme().
The UI pages by task
Each part of building a UI has its own page:
- Open a screen, go to the next one and back, transitions — Screens & Router:
UIScreen,Router.push/pop/replace - A bottom tab bar, pages that swipe — UIPager & UITabs
- Dialogs, bottom sheets, popovers, a HUD over a screen — Widgets
- Rows, columns, centering, a scrolling body, a carousel — Containers:
UIRow,UIColumn,UIBox,UIScrollable,UISpacer - Long feeds and chats — Virtualized lists:
UIVirtualizedList - Text, images, icons, video — Content elements:
UIText,UIImage,assetIcon,UIVideo - Buttons and text fields — Interactive elements:
UIButton,UIInput,UITextArea - Colors, sizes, spacing, flex, safe areas, animations — Styling:
.style(),animateTo - App-wide colors and a dark mode — Theme:
theme() - A selected or pressed look — Style classes:
$nameblocks,el.class - Fonts — Fonts:
font(),registerFont - Text, styles and lists that follow app state — Signals:
signal,computed - Taps, long presses, drags — Pointer events & gestures
- A map, a camera preview or another platform view — NativeView
Structure an app: a theme file, screen files and main.ts
A multi-screen app splits into a theme module that calls theme() once and exports
its accessors, one file per screen that exports it, and main.ts, which builds the
UITabs shell and hands it to the Router.
// theme.ts — one call: the roles the SDK reads (screens, default text, the tab bar) + your own keys
export const colors = theme({
bg: "#F4F6F5", surface: "#FFFFFF", border: "#E4E8E6",
text: "#131A17", textMuted: "#606B65", accent: "#15A34A",
})
// screens/home.ts
import { colors } from '../theme'
export const homeScreen = UIScreen(
UIScrollable(
UIText("Home").style({ color: colors.text, fontSize: 22, fontWeight: 700 }),
).style({ flexGrow: 1, px: "comfort-x" }),
).style({ bgColor: colors.bg, pt: "comfort-top" })
// screens/profile.ts
import { colors } from '../theme'
export const profileScreen = UIScreen(
UIText("Profile").style({ color: colors.text, fontSize: 22, fontWeight: 700 }),
).style({ bgColor: colors.bg, px: "comfort-x", pt: "comfort-top" })
// main.ts
import { homeScreen } from './screens/home'
import { profileScreen } from './screens/profile'
Router.init(UITabs({
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
}))An app with one screen needs no shell and no Router: screen.open().
Create elements: factory functions, not new
Every element is created by calling a global function — never new:
UIColumn(...children) // containers: children as plain arguments
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(), .named() — each returns the element itself, so
construction reads as one chain. (.animateTo() returns its Animation instead — call it on the
element, not inside a chain you pass as a child.)
there is no style argument: UIText({ fontSize: 20 }, "Hi") is a type error. Styles go
through .style(), a semantic name through .named("x"). Only UISpacer, UIInput and
UITextArea, whose factories take no content, accept a style bag as their one argument (the
same as .style()).
Elements carry a readonly type string ("column", "text", "screen", …) naming what they are.
An element is a handle: parent, destroy()
An element is a handle to a node in the runtime's UI tree. The node exists from the factory call
on; every style, content and structure write goes to it at once, on screen or not. What the
runtime owns is read back: children, parent.
el.parent: UINode | null // the parent element; null for an opened or pushed screen and a detached subtree
el.destroy(): void // free a DETACHED subtree now (remove / close / hide it first)There is no dispose(). A detached subtree you no longer reference is freed by itself; one you
keep stays valid and can be appended again. Closing or popping a screen detaches it — it is not
freed while you hold it.
destroy() on an element that is still attached is refused (the runtime logs an
error and the node stays on screen), but the element is dead from then on — its writes do
nothing. Remove it first.
Children are arguments: a mapped array drops in
Containers take their children as plain arguments, and an argument that is an array is
flattened one level — so a list built with .map() goes in directly, no spread:
const fruits = ["Apple", "Banana", "Cherry"]
const Row = (name: string) => UIText(name).style({ color: "white", fontSize: 18 })
UIScreen(
UIText("Fruits").style({ color: "white", fontSize: 24, fontWeight: 700 }),
fruits.map(Row), // an array argument: flattened into the children
UIText(`${fruits.length} items`).style({ color: "#888" }),
).style({ bgColor: "black", p: 20, gap: 8 }).open()A container whose only argument is a function treats it as a reactive children binding: the
list follows a signal — see
signals.
Anything else in a child slot (a string, a style object, the Animation that animateTo()
returns) is reported with console.error and skipped; a widget (UIWidget, UIModal, …) throws —
it goes in a screen's .widgets(...) (widget.md). A single array of children,
UIColumn([a, b]), is the same flatten rule on one argument.
Show an element only when a condition holds: null children
A null, undefined or false child is skipped entirely — no element, no layout slot — so a
ternary or && in the children leaves an element out:
const user = { name: "Ann", isAdmin: false, unread: 3 }
UIScreen(
UIText(`Hello, ${user.name}`).style({ color: "white", fontSize: 22 }),
user.unread > 0 ? UIText(`${user.unread} new messages`).style({ color: "#FF4032" }) : null,
user.isAdmin && UIButton(UIText("Admin panel").style({ color: "white" })),
).style({ bgColor: "black", p: 20, gap: 8 }).open()The condition is read once, when the container is built. To show or hide an element when a
value changes later, put the condition in a children function over a signal —
signals — or write display: "none" /
"flex" in its style.
const isGroup = false
const members = UIButton(UIText("Members"))
UIRow(isGroup ? members : UIColumn()) // ✗ an empty container as a placeholder still takes a flex slot
UIRow(isGroup ? members : null) // ✓ null is skippedKeep a reference to a nested element to change it later
To reach an element built deep inside the children — a label to rewrite, a field to read — assign it to a variable right in the children: the assignment both sets the variable and adds the element.
let label: UIText
let input: UIInput
UIScreen(
label = UIText("Hello").style({ color: "white", fontSize: 22 }),
input = UIInput({ placeholder: "Your name" }),
UIButton(UIText("Greet").style({ color: "white" }))
.named("greet")
.onClick(() => { label.text = `Hello, ${input.value}` }),
).style({ bgColor: "black", p: 20, gap: 12 }).open()let is a statement, not an expression — declare outside, assign inside:
UIRow(let input = UIInput()) is a syntax error. Prefer a captured reference over indexing
container.children — its entries are typed as the base UINode, so reading .text or .value
back needs a cast.
Update what an element shows: text, value, src
What an element shows — a text, a field's value, an image — is a property of the element, not a style. Writing it updates the screen at once, whether the element is on screen or not:
uiText.text: string // UIText
input.value: string // UIInput / UITextArea
image.src: ImageSource // UIImage
video.player: VideoPlayer // UIVideo — read-only: the player it showsInstead of writing it from every handler, an element can follow a signal: give UIText (or a
style key) a function, and it updates whenever a signal it read changes
(signals.md).
const cart = signal<string[]>([])
UIScreen(
UIText(() => `In the cart: ${cart.value.length}`).style({ color: "white", fontSize: 22 }),
UIButton(UIText("Add an apple").style({ color: "white" }))
.named("add")
.onClick(() => { cart.value = [...cart.value, "apple"] }), // a new array: the text updates
).style({ bgColor: "black", p: 20, gap: 12 }).open()Styles carry appearance and layout only: an image is a UIImage, never a bgImage of a box
(content.md).
Add, remove and replace children from code: append, remove, setContent
Containers (UIRow, UIColumn, UIBox, UIScrollable, UIScreen, …) change their children
only when you say so — there is no diffing:
container.append(...children): this // add at the end
container.insert(index, ...children): this // add at an index of .children
container.remove(...children): this // detach; a removed element stays reusable
container.setContent(children | () => children): this // replace all (a function = reactive)
container.children: UINode[] // a snapshot read back from the runtimelet count = 0
const list = UIColumn().style({ gap: 6 })
UIScreen(
UIRow(
UIButton(UIText("Add").style({ color: "white" })).named("add")
.onClick(() => { list.append(UIText(`Item ${++count}`).style({ color: "white" })) }),
UIButton(UIText("Remove first").style({ color: "white" })).named("remove")
.onClick(() => { const first = list.children[0]; if (first) list.remove(first) }),
UIButton(UIText("Clear").style({ color: "white" })).named("clear")
.onClick(() => { list.setContent([]) }),
).style({ gap: 12 }),
list,
).style({ bgColor: "black", p: 20, gap: 12 }).open()All of them work before and after the element is on screen. A node has one parent: append it
elsewhere only after remove (appending a still-attached node is refused with a logged error).
For long or unbounded data, use UIVirtualizedList instead of
setContent over a big array.
Measure an element's width, height and position: onLayout, getBoundingClientRect
An element's width and height exist only after layout: register onLayout to get them each time
its box changes, or call getBoundingClientRect() to read its size and position on screen at a
given moment.
el.onLayout(cb: (box: { left, top, width, height }) => void): this // logical px, parent-relative
el.getBoundingClientRect(): { x, y, left, top, right, bottom, width, height } | null // screen spaceconst card = UIBox(UIText("Card").style({ color: "white" }))
.style({ height: 120, bgColor: "#333", borderRadius: 12 })
.onLayout(({ width, height }) => console.log(`card ${width}x${height}`))
UIScreen(
card,
UIButton(UIText("Where is it?").style({ color: "white" })).named("where").onClick(() => {
const r = card.getBoundingClientRect()
if (r) console.log(`card at ${r.left},${r.top}, bottom ${r.bottom}`)
}),
).style({ bgColor: "black", p: 20, gap: 12 }).open()onLayout fires when the element is first laid out and again whenever its box changes (a
resize, new content) — not every frame; on the web only a change of size fires it, a move at the
same size does not. Several callbacks may be registered. Its left / top are relative to the
parent and go stale when an ancestor scrolls (scrolling moves elements without laying them out
again).
getBoundingClientRect() is read live at call time, in logical px of the whole screen, scroll
included — like the web API of the same name. It is null until the element has been laid out:
in the same tick as open() it is still null on every host but the web (which lays out at once),
so read it later — on a tap, or in onLayout. Its space is the one UIWidget positions in and
touch events report clientX / clientY in, which makes it the anchor for a dropdown, a popover
or a tooltip: read the trigger's rect on tap, place the widget at rect.left / rect.bottom
(widget.md). Read it once when the widget opens; don't poll it every frame.
Reusable components: a function that returns an element
A component is a function that returns an element — chain onto the result like any other
element. A shared style is a plain object typed with the global Style<T> helper (a type only —
nothing to import or create):
const heading: Style<UIText> = { color: "white", fontSize: 17, fontWeight: 700 }
const Card = (title: string, subtitle: string) => UIButton(
UIText(title).style(heading),
UIText(subtitle).style({ color: "#888", fontSize: 13 }),
).style({ px: 16, py: 12, gap: 4, flexDirection: "column", alignItems: "flex-start", borderRadius: 12 })
UIScreen(
Card("Inbox", "3 new messages").style({ bgColor: "#111" }).named("inbox")
.onClick(() => console.log("open inbox")),
Card("Settings", "Theme, account").style({ bgColor: "#111" }),
).style({ bgColor: "black", p: 20, gap: 12 }).open()In an app with a theme module, the hex literals become theme accessors
(color: colors.textMuted) and the components follow a re-theme by themselves.
el.named("x") (or el.name = "x") gives an element a semantic name — not a style. It
is surfaced on the node as a stable selector for tests ("tap": "inbox" in a flow) and review
tooling.
See also
- Styling — the
.style()model this page assumes - Theme — app-wide variables and live re-theming
- Style classes — named style states, the cascade
- Signals — state that updates text, styles and lists
- Pointer events & gestures —
onClick/onTouchStartevent objects - Conventions — logical px, chaining, lifecycle rules