UI element model
How the UI layer works as a whole: elements are plain objects built by global factory functions, composed by passing children as arguments, 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:
The style system
- Styling —
.style(), the property vocabulary, units, safe areas,animateTo - Theme —
theme()app-wide variables;var()values; live re-theming (dark mode is a second call) - Style classes —
$namestate blocks toggled viael.class; they cascade to descendants;$pressed/$focused - Fonts — the
font()macro,registerFont
Elements
- Containers —
UIRow,UIColumn,UIScrollable,UISpacer - Content elements —
UIText,UIImage,UIVideo,assetIcon - Interactive elements —
UIButton,UIInput,UITextArea - Virtualized lists —
UIVirtualizedListfor long feeds and chats
Navigation & overlays
- Screens & Router —
UIScreen,Router - UIPager & UITabs — swipeable tabs, per-tab stacks, the standard tab shell
- Widgets —
UIWidgetoverlays,UIModal,UIPopover,UIBottomSheet - NativeView — host-registered platform views (map, camera, …)
The shape of an app
The pattern the reference assumes, and the one real projects follow: a tokens module calls
theme() once and exports the accessors; screens are factory functions in their
own files; main.ts builds the UITabs shell and hands it to the Router.
// theme.ts
theme({ color: "#131A17", primaryColor: "#15A34A" }) // default text + kit accent
export const colors = theme({ bg: "#F4F6F5", card: "#FFFFFF", muted: "#606B65" })
// screens/home.ts
import { colors } from '../theme'
export const homeScreen = UIScreen(
UIScrollable(/* content */).style({ flexGrow: 1, px: "comfort-x" }),
).style({ bgColor: colors.bg, 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 },
}))A single-screen app skips the shell: screen.open() and done.
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: 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(), .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 & conditional rendering
Containers take their children as plain arguments. An argument that is an array is flattened one level — so a mapped list drops in directly, no spread:
UIColumn(
header,
items.map(Row), // an array argument — flattened into the children
footer,
)A null, undefined, or false child is skipped entirely — no element, no layout slot —
which is the conditional-rendering idiom (and works for whole blocks: a falsy argument is
skipped too):
UIColumn(
header,
isLoading ? spinner : null, // ternary with null
showFooter && footer, // && short-circuit
showList && items.map(Row), // conditional block
)A container whose only argument is a function treats it as a reactive children binding — see signals.
the pre-variadic form — a single array of children, UIColumn([a, b]) — still
works everywhere (it's just the flatten rule applied to one argument). Write new code with
children as arguments.
// ✗ 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 right in the children — 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 & getBoundingClientRect
Sizes exist only after layout. To react to an element's box, use onLayout:
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. Its coordinates are parent-relative — and they go stale when an ancestor scrolls (scrolling moves elements without relaying them out).
To read an element's absolute position on demand — like the web API of the same name — use:
el.getBoundingClientRect()
// { x, y, left, top, right, bottom, width, height } — device space, logical px, includes
// scroll offsets; read live at call time. null before the element is mounted (and on hosts
// that don't implement the read yet).The rect is in the same space UIWidget positions in and touch events report
clientX/clientY in — which makes it the anchoring primitive for dropdowns, popovers, and
tooltips: read the trigger's rect on tap, place the widget at rect.left/rect.bottom
(see UIWidget & UIModal). Anchor position-once at open; don't poll per frame.
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(() => { ... })In an app with a tokens module, the hex literals above become theme accessors
(color: colors.muted) — components then follow a re-theme automatically.
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 - Theme — app-wide variables and live re-theming
- Style classes — named style states, the cascade
- Pointer events & gestures —
onClick/onTouchStartevent objects - Conventions — logical px, chaining, lifecycle rules