UI containers
The structural elements: UIColumn stacks children vertically, UIRow horizontally,
UIScrollable makes an overflow region pan, UISpacer eats free space. Screens never scroll —
a screen that's one long flow is fixed chrome plus one UIScrollable body with flexGrow: 1;
see screen-router.md.
At a glance
const screen = UIScreen(
UIRow(
UIText("Library").style({ fontSize: 24, fontWeight: 700, color: "white" }),
UISpacer(), // pushes the count to the far edge
UIText("12 items").style({ color: "#888" }),
).style({ px: 16, py: 12, alignItems: "center" }),
UIScrollable(
UIColumn(UIText("First").style({ color: "white" })).style({ p: 16 }),
UIColumn(UIText("Second").style({ color: "white" })).style({ p: 16 }),
).style({ flexGrow: 1, gap: 8 }) // flexGrow: 1 = take the space under the header
.onScroll(pos => console.log("scrolled to", pos)),
).style({ bgColor: "black", pt: "safe-top" })
screen.open()UIRow / UIColumn
UIColumn(...children: (UINodeChild | UINodeChild[])[]): UIColumn
UIRow(...children: (UINodeChild | UINodeChild[])[]): UIRowChildren 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:
UIRow(UIColumn().style({ flexGrow: 1 }), label) // ✗ phantom spacer element
UIRow(label).style({ justifyContent: "flex-end" }) // ✓Managing children
All containers on this page share the same imperative child API (works before and after the element is on screen; no diffing):
c.append(...nodes: UINodeChild[]): this
c.insert(index: number, ...nodes: UINodeChild[]): this // index into c.children
c.remove(...nodes: UINode[]): this // removes by identity
c.setContent(children: UINodeChild[]): this // replace everything
c.children // readonly UINodeChild[]setContent is the "re-render" primitive — build a fresh array (e.g. items.map(Row)) and swap
it in. For long or unbounded data use UIVirtualizedList instead.
UIBox — deprecated
UIBox(...children): UIBox // legacy — use UIRow / UIColumnA legacy container whose only distinction is defaulting justifyContent and alignItems to
"center" (children centered on both axes). Kept for old projects; write new code with
UIRow/UIColumn plus explicit alignment.
UIScrollable
The scroll container — every scrolling region is one: the body of a long screen (screens never scroll themselves — see screen-router.md), a list under a pinned header, a horizontal carousel.
UIScrollable(...children: (UINodeChild | UINodeChild[])[]): UIScrollableExtra styles on top of the container surface:
scrollDirection: "horizontal" | "vertical" // default vertical
showScrollbar: boolean
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
// paging: when the drag ends, rest on a direct child's boundary along the scroll axis. The value
// picks where that child sits in the viewport — "start" edge-aligns it, "center" centers it,
// "end" trailing-aligns it. Snap targets are the children themselves, so their sizes can differ
// (a card carousel with peeking neighbors is `snap: "center"`). See "Carousel / pager" below.Scroll events (chainable, like all on*):
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
s.onOverscroll(cb: (delta: number) => void): UIScrollable // dragged past the edge (px)Pull-to-refresh — onRefresh
s.onRefresh(cb: () => void | Promise<void>): UIScrollable // spinner stays until the promise settlesUIScrollable(rows)
.style({ flexGrow: 1, refreshControlColor: "#FF4032" })
.onRefresh(async () => { await load() })attach onRefresh before the element mounts — hosts read the callback at node
creation. Vertical scrollables only, and native hosts only (web viewers no-op).
UIVirtualizedList has the same onRefresh + refreshControlColor.
there is no scrollTo — a UIScrollable's position can't be set
programmatically. This is a current limitation. If you need programmatic scrolling
(scrollTo / scrollToEnd / scrollToKey), use UIVirtualizedList.
UIScrollable defaults flexShrink: 1 (the one exception to the global
flexShrink: 0 default), 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.
Horizontal carousel:
UIScrollable(items.map(Card))
.style({ scrollDirection: "horizontal", showScrollbar: false, gap: 12, px: 16 })Carousel / pager — snap
snap makes a scrollable rest on child boundaries. There's no UISlider/UICarousel wrapper —
snap is the primitive, and a pager is a horizontal scrollable of full-width children plus
onScroll to track the active page:
UIScrollable(slides.map(s => Slide(s).style({ width: "100%" })))
.style({ scrollDirection: "horizontal", showScrollbar: false, snap: "start" })Current page. There is no page event — the index is onScroll divided by the page width.
Measure the step with onLayout (never assume the screen width: padding, insets and split views
all change it), and drive a signal so dots/labels bind to it:
const page = signal(0)
let step = 1
const track = UIScrollable(slides.map(Slide))
.style({ scrollDirection: "horizontal", snap: "start" })
.onLayout(({ width }) => { if (width > 0) step = width })
.onScroll(x => { page.value = Math.max(0, Math.min(slides.length - 1, Math.round(x / step))) })page updates continuously during the drag — it flips as a slide passes the halfway mark, which
is what a dot indicator should do. There's no "settled on page n" callback; onScrollRelease
fires at finger-lift, before the snap animation finishes.
A card carousel where neighbors peek uses narrower children and snap: "center". Snap targets
are the direct children, so the cards can be different widths (a counter over uneven children
needs their offsets, not a single step).
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 by construction.
snap settles gestures only; hosts without support degrade to free scrolling (web-lite
full, wasm viewer none yet). Combine with onScroll for the index — there's still no
programmatic scrollTo on UIScrollable (see the note above), so dot-tap navigation needs
UIVirtualizedList or waits on that gap.
Draggable children inside a scroller need to claim their gesture direction or the scroll steals
the pointer — see touch.md.
UISpacer
UISpacer(): UISpacer // no children; its style surface is ElementStyle only — no backgroundFlexible empty space: it defaults to flexGrow: 1, eating free space along the parent's main
axis. Reach for it only when plain alignment can't express the layout — one item pushed to the
far end while the rest stay put:
UIRow(title, UISpacer(), closeButton)If all children move together, justifyContent ("space-between", "flex-end", …) does the
same job with no extra element.
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