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
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.
UIColumn(...children: (UINodeChild | UINodeChild[])[]): UIColumn
UIRow(...children: (UINodeChild | UINodeChild[])[]): UIRowconst 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:
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 styleAdd, 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):
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 nowconst 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.
UIBox(...children: (UINodeChild | UINodeChild[])[]): UIBoxconst 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.
UISpacer(): UISpacer // no children; element styles only — `bgColor`, but no border / bgImageconst 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.
UIScrollable(...children: (UINodeChild | UINodeChild[])[]): UIScrollableExtra styles on top of the container surface:
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 belowUIScrollable 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.
there is no scrollTo — a UIScrollable's position can't be set
programmatically. If you need programmatic scrolling (scrollTo / scrollToEnd /
scrollToKey), use UIVirtualizedList.
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.
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)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()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.
s.onRefresh(cb: () => void | Promise<void>): UIScrollable // spinner stays until the promise settlesconst 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()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.
Carousel that snaps to each card: snap
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":
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.
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:
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
- UI element model — children, conditional rendering, refs
- Styling — the container style vocabulary (
gap,alignItems, …) - Screens & navigation — screens never scroll; fixed chrome + a scrollable body
- Virtualized lists — windowed rendering + programmatic scrolling
- UIPager — pages of screens that swipe, tabs