LeCodesdocs

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

TypeScript
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

TypeScript
UIColumn(...children: (UINodeChild | UINodeChild[])[]): UIColumn
UIRow(...children: (UINodeChild | UINodeChild[])[]): UIRow

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

TypeScript
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

TypeScript
UIBox(...children): UIBox   // legacy — use UIRow / UIColumn

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

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

Extra styles on top of the container surface:

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

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
s.onOverscroll(cb: (delta: number) => void): UIScrollable        // dragged past the edge (px)

Pull-to-refresh — onRefresh

TypeScript
s.onRefresh(cb: () => void | Promise<void>): UIScrollable   // spinner stays until the promise settles
TypeScript
UIScrollable(rows)
  .style({ flexGrow: 1, refreshControlColor: "#FF4032" })
  .onRefresh(async () => { await load() })
Note

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.

Note

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.

Note

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:

TypeScript
UIScrollable(items.map(Card))
  .style({ scrollDirection: "horizontal", showScrollbar: false, gap: 12, px: 16 })

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:

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

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

Note

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

TypeScript
UISpacer(): UISpacer      // no children; its style surface is ElementStyle only — no background

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

TypeScript
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