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

TypeScript
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: $name blocks, 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.

TypeScript
// 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:

TypeScript
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.)

Note

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.

TypeScript
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.

Note

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:

TypeScript
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:

TypeScript
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.

TypeScript
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 skipped

Keep 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.

TypeScript
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:

TypeScript
uiText.text: string           // UIText
input.value: string           // UIInput / UITextArea
image.src: ImageSource        // UIImage
video.player: VideoPlayer     // UIVideo — read-only: the player it shows

Instead 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).

TypeScript
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:

TypeScript
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 runtime
TypeScript
let 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.

TypeScript
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 space
TypeScript
const 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):

TypeScript
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.

Note

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