UI containers

The layout elements: UIColumn stacks children top to bottom, UIRow side by side, UIBox centers them, UISpacer pushes its siblings apart, UIScrollable scrolls what does not fit. UIScrollable scrolls content that exists (a form, an article); a long or endless list of data → UIVirtualizedList, which builds only the rows on screen.

  • stack elements top to bottom or side by side (UIColumn, UIRow)
  • center an icon or a letter inside a circle (UIBox)
  • push a button to the right end of a row (UISpacer)
  • add, remove or replace children after the screen is shown
  • make a long screen scroll under a fixed header (UIScrollable)
  • change the header when the user scrolls (onScroll)
  • pull down to refresh the content (onRefresh)
  • scroll a row of cards sideways and stop on each card (snap)
  • show which slide of a carousel is visible

Platforms: every host; pull-to-refresh (a touch pull) and snapping only on iOS, Android and the web — not on desktop, in the web player or in headless tests.

At a glance

TypeScript
const folders = ["Inbox", "Starred", "Sent", "Drafts", "Archive", "Spam", "Trash", "Updates", "Forums", "Social", "Travel", "Receipts"]

const screen = UIScreen(
  UIRow(
    UIText("Mail").style({ fontSize: 24, fontWeight: 700, color: "white" }),
    UISpacer(),                                          // pushes the count to the far edge
    UIText(`${folders.length} folders`).style({ color: "#888" }),
  ).style({ px: 16, py: 12, alignItems: "center" }),    // the header stays put

  UIScrollable(
    folders.map(name => UIColumn(UIText(name).style({ color: "white" }))
      .style({ height: 64, justifyContent: "center", px: 16, bgColor: "#16181d" })),
  ).style({ flexGrow: 1, gap: 8 })                      // flexGrow: 1 = the space under the header
    .onScroll(y => console.log("scrolled to", y)),      // logical px from the top
).style({ bgColor: "black", pt: "safe-top" })

screen.open()

Stack elements in a column or a row: UIColumn / UIRow

UIColumn puts its children top to bottom, UIRow side by side; gap spaces them, justifyContent places them along the line and alignItems across it.

TypeScript
UIColumn(...children: (UINodeChild | UINodeChild[])[]): UIColumn
UIRow(...children: (UINodeChild | UINodeChild[])[]): UIRow
TypeScript
const card = UIColumn(                                   // children top to bottom
  UIText("Order #1042").style({ fontSize: 18, fontWeight: 700, color: "white" }),
  UIText("2 items · $24.00").style({ color: "#aaa" }),
  UIRow(                                                 // children side by side
    UIButton(UIText("Track").style({ color: "white" }))
      .style({ px: 16, height: 40, bgColor: "#2b3550", borderRadius: 8 }).named("track"),
    UIButton(UIText("Cancel").style({ color: "white" }))
      .style({ px: 16, height: 40, bgColor: "#3a2020", borderRadius: 8 }).named("cancel"),
  ).style({ gap: 8, justifyContent: "flex-end" }),      // the buttons sit at the right edge
).style({ gap: 6, p: 16, bgColor: "#16181d", borderRadius: 12 })

UIScreen(card).style({ bgColor: "black", p: 16, pt: "safe-top" }).open()

Children are variadic; an argument that is an array is flattened one level, so UIColumn(header, items.map(Row), footer) needs no spread. A lone function argument is a reactive children binding — see signals. The only difference between the two containers is the default flexDirection (column vs row) — both take the full container style surface (UIContainerStyle = element + drawable + container layout props, see styling.md). null / undefined / false children are skipped — see overview.md.

UIButton is a full container too, but with different defaults: row plus children centered on both axes — see interactive.md.

Avoid wrapper containers that only exist to align something — alignment is a container property:

TypeScript
const price = () => UIText("$12.00").style({ color: "white" })   // a fresh node per row: a node has one parent

UIRow(UIColumn().style({ flexGrow: 1 }), price())      // ✗ an empty column as a spacer
UIRow(price()).style({ justifyContent: "flex-end" })    // ✓ alignment is the row's own style

Add, remove or replace children: append / remove / setContent

A container's children can change after it is on screen: append / insert add, remove takes out, setContent replaces them all. Every container on this page has the same methods (they work before and after the element is shown; no diffing):

TypeScript
c.append(...nodes: UINodeChild[]): this
c.insert(index: number, ...nodes: UINodeChild[]): this   // index into c.children
c.remove(...nodes: UINodeChild[]): this                   // detaches by identity — the subtree stays valid, re-appendable
c.setContent(children: UINodeChild[] | (() => UINodeChild[])): this   // replace everything; a function = reactive children
c.children                                                 // a read-only SNAPSHOT of the mounted children
c.parent                                                   // the parent element, or null
c.destroy()                                                // free a DETACHED element and its whole subtree now
TypeScript
const list = UIColumn().style({ gap: 8 })
let n = 0

const add = UIButton(UIText("Add").style({ color: "white" }))
  .style({ height: 44, bgColor: "#2b3550", borderRadius: 8 })
  .onClick(() => {
    list.append(UIText(`Item ${++n}`).style({ color: "white" }))
    console.log(`Item ${n} added, children: ${list.children.length}`)   // read back from the runtime
  })

const clear = UIButton(UIText("Clear").style({ color: "white" }))
  .style({ height: 44, bgColor: "#3a2020", borderRadius: 8 })
  .onClick(() => list.setContent([]))                   // replace everything (here: with nothing)

UIScreen(add, clear, list).style({ bgColor: "black", p: 16, gap: 12, pt: "safe-top" }).open()

The tree lives in the runtime: children is read back from it on every access (never mutate the array — use the methods), a node has exactly ONE parent (appending a node that is still attached elsewhere is refused with a logged error — remove it first), falsy entries were never mounted, and parent resolves while the parent itself is attached (under a presented screen, a shown widget, a pager page). destroy() works on a detached subtree only: on an attached one the runtime refuses (a logged error, the node stays) while the element is dead from then on. named("x") gives an element a semantic name (el.name) — a stable selector for tests and review feedback.

setContent is the "re-render" primitive — build a fresh array (e.g. items.map(Row)) and swap it in. Children that follow a signal are a function instead: UIColumn(() => items.value.map(Row)) (signals). For long or unbounded data use UIVirtualizedList.

Center content in a box: UIBox

UIBox centers its children on both axes — an icon in a circle, an avatar letter, an empty state.

TypeScript
UIBox(...children: (UINodeChild | UINodeChild[])[]): UIBox
TypeScript
const avatar = UIBox(UIText("A").style({ color: "white", fontWeight: 700 }).named("initial"))
  .style({ width: 40, height: 40, borderRadius: 20, bgColor: "#5b8cff" })   // a round badge, the letter centered
  .named("avatar")

UIScreen(avatar).style({ bgColor: "black", p: 24, pt: "safe-top" }).open()

It is a column whose justifyContent and alignItems default to "center". Same arguments and style surface as UIColumn; its defaults are ordinary styles, so .style({ alignItems: "stretch" }) overrides one.

Push a button to the right end of a row: UISpacer

UISpacer is empty space that grows: put it between a title and a button in a header row, and the button goes to the right edge while the title stays on the left.

TypeScript
UISpacer(): UISpacer      // no children; element styles only — `bgColor`, but no border / bgImage
TypeScript
const header = UIRow(
  UIText("Settings").style({ fontSize: 20, fontWeight: 700, color: "white" }),
  UISpacer(),                                           // takes the free space: Close goes to the right edge
  UIButton(UIText("Close").style({ color: "white" }))
    .style({ px: 12, height: 36, bgColor: "#2b3550", borderRadius: 8 })
    .named("close")
    .onClick(() => Router.pop()),
).style({ alignItems: "center", px: 16, py: 12 })

const settings = UIScreen(header).style({ bgColor: "black", pt: "safe-top" })
Router.init(UIScreen(UIText("Home").style({ color: "white" })).style({ bgColor: "black", p: 24 }))
Router.push(settings)

It defaults to flexGrow: 1, eating the free space along the parent's main axis. Having no content, it also takes a style bag as its argument — UISpacer({ flexGrow: 2 }) ≡ UISpacer().style({ flexGrow: 2 }). Reach for it only when one item moves while the rest stay put; if all children move together, justifyContent ("space-between", "flex-end", …) does the same job with no extra element.

Scroll a long screen: UIScrollable

Content taller (or wider) than its space scrolls inside a UIScrollable: the body of a long screen under a fixed header, a form, an article, a horizontal row of cards. Screens never scroll themselves — a long screen is fixed chrome plus one UIScrollable body with flexGrow: 1 (the At a glance above; screen-router.md). A long or endless list of data is a UIVirtualizedList: it builds only the rows on screen and scrolls from code.

TypeScript
UIScrollable(...children: (UINodeChild | UINodeChild[])[]): UIScrollable

Extra styles on top of the container surface:

TypeScript
scrollDirection:     "horizontal" | "vertical"      // default vertical; horizontal lays the children in a row
showScrollbar:       boolean                        // default true
overscrollMode:      "none" | "absorb" | "default"  // edge behavior when dragged past the content
refreshControlColor: Color                          // tints the pull-to-refresh spinner
keyboardDismissMode: "interactive" | "scroll" | "none"
// how a scroll gesture in THIS scrollable dismisses the keyboard. "interactive" (default):
// dragging down over the keyboard slides it away (chat feel). "scroll": any drag dismisses the
// moment it starts (search-results feel). "none": scrolling never dismisses. See the keyboard
// dismissal doctrine in interactive.md.
snap:                "none" | "start" | "center" | "end"   // default none — see the carousel below
Note

UIScrollable defaults flexShrink: 1 (an exception to the global flexShrink: 0 default, like UIVirtualizedList and UIPager), so a vertical scrollable in a column shrinks and scrolls instead of overflowing. Wrapping containers between it and the screen still default to 0 and need flexShrink: 1 by hand; set flexShrink: 0 back on the scrollable to opt out. flexGrow: 1 for fill-the-remaining-space is still yours to set.

Note

there is no scrollTo — a UIScrollable's position can't be set programmatically. If you need programmatic scrolling (scrollTo / scrollToEnd / scrollToKey), use UIVirtualizedList.

Note

on desktop the wheel and touch scroll; a mouse drag scrolls only with "desktop": { "mouseEmulateTouch": true } in app.json. showScrollbar and overscrollMode take effect on iOS, Android and the web; desktop, the web player and headless tests draw no scrollbar and stop at the edge.

A draggable child inside a scroller has to claim its gesture direction, or the scroll takes the pointer — see claim (honoured on iOS, Android and the web).

Change the header when the user scrolls: onScroll

onScroll of a UIScrollable reports how far its content is scrolled, in logical px from the start edge, on every change — drive a signal from it to restyle a header, show a hint, or count pages.

TypeScript
s.onScroll(cb: (scrollPosition: number) => void): UIScrollable   // logical px from the start edge
                                                                 // (horizontal reports the x offset)
s.onScrollRelease(cb: () => void): UIScrollable                  // finger lifted (the web: the scroll ended)
s.onOverscroll(cb: (delta: number) => void): UIScrollable        // dragged past the edge (px)
TypeScript
const scrolled = signal(false)

const header = UIRow(UIText("Inbox").style({ fontSize: 20, fontWeight: 700, color: "white" }))
  .style({ px: 16, py: 12, bgColor: () => scrolled.value ? "#1c1f26" : "#000000" })   // follows the signal
  .named("header")

const body = UIScrollable(
  Array.from({ length: 20 }, (_, i) => UIText(`Message ${i + 1}`).style({ color: "white", height: 56 })),
).style({ flexGrow: 1, px: 16 })
  .onScroll(y => { scrolled.value = y > 0 })            // y: logical px scrolled from the top

UIScreen(header, body).style({ bgColor: "black", pt: "safe-top" }).open()
Note

on the web onOverscroll fires only for a touch pull down at the top (no bottom edge, no mouse or wheel).

Pull to refresh the content: onRefresh

onRefresh puts a pull-to-refresh spinner on a vertical UIScrollable: the user pulls the content down, your callback reloads it, and the spinner stays until its promise settles.

TypeScript
s.onRefresh(cb: () => void | Promise<void>): UIScrollable   // spinner stays until the promise settles
TypeScript
const items = signal<string[]>(["First", "Second"])

const load = async () => {
  const res = await fetch("https://example.com/items")
  items.value = res.json<string[]>()
}

const body = UIScrollable(() => items.value.map(t => UIText(t).style({ color: "white", p: 16 })))
  .style({ flexGrow: 1, refreshControlColor: "#FF4032" })
  .onRefresh(load)                                      // the spinner stays until load() settles

UIScreen(body).style({ bgColor: "black", pt: "safe-top" }).open()
Note

vertical scrollables only, on iOS, Android and the web (a touch pull; a mouse or a wheel never pulls). Desktop, the web player and headless tests have no pull, and the callback never fires there. UIVirtualizedList has the same onRefresh + refreshControlColor.

A horizontal UIScrollable with snap is a carousel: when the drag ends, it rests on a direct child instead of wherever the fling stopped. There is no UICarousel / UISlider wrapper. snap picks where the child rests — "start" at the leading edge, "center" centered, "end" at the trailing edge. Snap targets are the children themselves, so their sizes can differ; a card carousel where the neighbours peek is narrower cards plus snap: "center":

TypeScript
const cards = ["Paris", "Rome", "Lisbon", "Vienna"]

const carousel = UIScrollable(
  cards.map(city => UIBox(UIText(city).style({ color: "white", fontSize: 20 }))
    .style({ width: 280, height: 160, borderRadius: 16, bgColor: "#2b3550" })),
).style({ scrollDirection: "horizontal", showScrollbar: false, snap: "center", gap: 12, px: 16 })
  .named("cities")

UIScreen(carousel).style({ bgColor: "black", pt: "safe-top" }).open()

Full-screen slides (onboarding) are children of width: "100%" with snap: "start" — the next section counts them. For pages that each hold a screen, with tabs, use UIPager.

On iOS and Android start and end account for the scrollable's own padding: with paddingHorizontal: 20 a child comes to rest at the 20px gutter, not flush against the screen edge, so the snap positions agree with the container's natural rest position. center is padding-independent.

Note

snap settles gestures only, on iOS, Android and the web. Desktop, the web player and headless tests scroll freely and rest where the scroll stops.

Show which slide is visible: a page indicator

There is no page event — the visible slide's index is the onScroll position divided by the slide width. Measure the width with onLayout (never assume the screen width: padding, insets and split views all change it), and drive a signal that dots or a counter bind to:

TypeScript
const slides = ["Welcome", "Sync your notes", "Share with friends"]
const page = signal(0)
let step = 1                                            // one slide's width, measured

const track = UIScrollable(
  slides.map(t => UIBox(UIText(t).style({ color: "white", fontSize: 22 })).style({ width: "100%", height: 240 })),
).style({ scrollDirection: "horizontal", showScrollbar: false, snap: "start" })
  .named("track")
  .onLayout(({ width }) => { if (width > 0) step = width })
  .onScroll(x => { page.value = Math.max(0, Math.min(slides.length - 1, Math.round(x / step))) })

const counter = UIText(() => `${page.value + 1} / ${slides.length}`).style({ color: "#888" })

UIScreen(track, counter).style({ bgColor: "black", gap: 12, pt: "safe-top" }).open()

page updates continuously during the drag — it flips as a slide passes the halfway mark, which is what a dot indicator should do. There is no "settled on page n" callback; on the phones onScrollRelease fires at finger-lift, before the snap animation finishes. With uneven children a single step does not work: the counter needs each child's offset. There is no programmatic scrollTo on UIScrollable, so tapping a dot to jump needs UIVirtualizedList.

Compiler fast paths — __UIColumn & friends

__UIColumn, __UIRow, __UIBox, __UIButton, __UIScreen, __UIScrollable, __UIWidget are dispatch-free construction paths the chisel flatten_ui pass lowers factory calls to when it has proven the arguments are plain children (e.g. UIColumn(a, b) → __UIColumn([a, b])). They are compiler output — never call them by hand; the public factories are the API.

See also