LeCodesdocs

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
  • Themetheme() app-wide variables; var() values; live re-theming (dark mode is a second call)
  • Style classes$name state blocks toggled via el.class; they cascade to descendants; $pressed/$focused
  • Fonts — the font() macro, registerFont

Elements

Navigation & overlays

  • Screens & RouterUIScreen, Router
  • UIPager & UITabs — swipeable tabs, per-tab stacks, the standard tab shell
  • WidgetsUIWidget overlays, 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.

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

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

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(), .animateTo() — each returns the element itself, so construction reads as one chain.

Legacy

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:

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

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

Legacy

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.

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

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

TypeScript
UIRow(let input = UIInput())   // ✗ syntax error

Prefer captured refs over indexing container.childrenchildren 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:

TypeScript
text.text = "Updated"        // UIText
input.value = ""             // UIInput / UITextArea
image.src = newUrl           // UIImage
video.player                 // UIVideo — read-only link to its VideoPlayer

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

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

TypeScript
el.onLayout(({ left, top, width, height }) => { ... })   // logical px; top/left relative to the parent

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

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

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

Note

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