UIPager
UIPager is the one screen-navigation element: sibling tabs that swipe natively side to side,
each with its own navigation stack. Pass an array of screens and they sit next to each other;
push opens a screen on top of the current tab (within the pager), and the native edge
back-swipe (or pop) unwinds it. It maps to each platform's native containers (iOS
UIPageViewController + a UINavigationController per tab), so swipes, pushes and the back
gesture are the real thing — not reimplemented.
const pager = UIPager(HomeScreen(), SearchScreen(), ProfileScreen())
// later, from anywhere inside a hosted screen — no reference threading:
UIPager.push(PostScreen(id)) // slides in on top of the current tab
UIPager.pop()A pager with one tab is simply a navigation stack — there is no separate stack element. Hosts
take this literally: with one tab, iOS skips the paging container entirely and embeds just the
UINavigationController, so there is zero overhead versus a dedicated stack component.
UITabs — the standard bottom-tab shell
For the common shape — pager + bottom bar — use UITabs instead of composing by hand. It is pure
SDK composition (a UIScreen of [ UIPager (flexGrow: 1), themed bar ], zero new ABI) with the
selection sync, active highlight, safe-area padding and badges built in. Keys are tab ids, in tab
order; buttons are named tab-<id> (flow tests can tap them by name):
const tabs = UITabs({
home: { label: "Home", icon: assetIcon("lucide:house"), screen: homeScreen },
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profileScreen },
})
Router.init(tabs) // UITabs IS a UIScreen / Presentable
tabs.select("profile") // switch tabs (instant; `true` slides)
tabs.tab // active tab id (getter)
tabs.onSelect((id, i) => ...) // bar tap, swipe, or select()
tabs.badge("home", 3) // count pill; `true` = dot; false/null/0 clears
tabs.pager // the pager underneath (push/pop/depth)The bar styles itself through theme variables, with fallbacks so an unthemed app still
looks right: active var(--primaryColor), inactive var(--mutedColor), surface var(--tabbarBg),
hairline var(--tabbarBorder), badge var(--badgeColor) — one theme({ primaryColor, tabbarBg })
call restyles it app-wide (the same variables the design scaffold's defineTabs bar reads). The
active button also carries the active style class (cascades to its content). For finer control
restyle tabs.bar; for a fully custom bar, drop down to UIPager + your own row.
In-tab navigation is unchanged: UIPager.push(detail) from any screen keeps the bar;
Router.push(...) opens above the whole shell (bar covered).
Construct
UIPager(root) // one tab = a plain stack rooted at `root`
UIPager(a, b, c) // three tabs, side by side (a is selected)
// Tabs are variadic like container children: an array argument is flattened, falsy entries are
// skipped — UIPager(home, isAdmin && adminTab) works. Style via chained .style({...}).Children are UIScreens only — every page carries the full screen contract
(onOpen/onClose, onBackPressed), laid out to the pager's box. To host a
plain element, wrap it: UIPager(UIScreen(el)). Tabs are fixed at construction — the
dynamic parts are selection and each tab's stack.
A pager fills its slot like any element — give it flexGrow: 1 (or a size) so it doesn't
collapse (flexShrink: 1 is already the default, like UIScrollable's, so it leaves room for
the tab bar):
UIScreen(
UIPager(HomeTab(), ProfileTab()).style({ flexGrow: 1 }),
tabBar,
)Tabs
pager.select(2) // instant switch (no-op if out of range)
pager.select(2, true) // animated, direction-aware slide
pager.index // the selected tab (getter)
pager.onSelect(i => { ... }) // fires on a settled swipe or select()select is instant by default — the platform convention for tab bars, where tabs are
parallel modes rather than a left-to-right sequence. Pass animated: true for the sliding
switch of top-tab UIs (the Material feel). Swiping is always animated — it's a physical drag.
Tabs keep their stacks: swipe away from a tab that's three screens deep, swipe back, and it is exactly where you left it. Tab roots stay alive — switching tabs never rebuilds them.
Swiping between tabs works only at the tab root. Once the current tab is drilled in
(depth > 1), the horizontal gesture belongs to the edge back-swipe; paging re-enables when the
tab is back at its root. This is the native arbitration — there is nothing to configure.
The stack (per tab)
pager.push(screen) // slide in a new top on the CURRENT tab; back-swipe pops it
pager.pop() // pop the current tab's top (no-op at the tab root)
pager.popToRoot() // unwind the current tab to its root
pager.replace(screen) // swap the current tab's top (at depth 1: swaps the tab's root)
pager.stack // UIScreen[] of the current tab, root → top (getter)
pager.depth // 1 = only the root (getter)
pager.onChange(depth => { ... }) // fires on push, pop, replace, or back-swipeThere is no per-push transition option — it's the fixed native push/pop, which is exactly what lets the back gesture drag-reverse it.
Reach it from anywhere — UIPager.current
UIPager is a global, so you don't thread a reference into child screens. The ambient calls act
on the pager that owns the currently visible screen:
UIPager.current // the pager whose screen is visible, or null
UIPager.push(screen) // → current.push(screen)
UIPager.pop() // → current.pop()
UIPager.popToRoot()const HomeTab = () => UIScreen(
row("Open the first post", () => UIPager.push(PostScreen(posts[0]))),
)With pagers nested inside pages, UIPager.current resolves to the innermost one — push
from inside a page joins the pager the user is actually looking at. If the visible screen has no
enclosing pager, current is null and the ambient calls no-op with a warning.
Tab bars
UITabs ships the standard bottom bar. For a custom
bar over a raw pager, compose one and drive it both ways:
const pager = UIPager(HomeTab(), ProfileTab())
const tabBar = UIRow(
UIButton(UIText("Home")).onClick(() => pager.select(0)),
UIButton(UIText("Profile")).onClick(() => pager.select(1)),
)
pager.onSelect(i => highlight(tabBar, i)) // swipes move the highlight too
Router.init(UIScreen(pager.style({ flexGrow: 1 }), tabBar))Back button (Android) & back-swipe
The interactive edge back-swipe pops the current tab automatically. For a hardware/software back button, pop the current tab first, then fall through to your own handling:
screen.onBackPressed(() => {
if (UIPager.current && UIPager.current.depth > 1) {
UIPager.current.pop()
return
}
// ...your own back behavior (exit, confirm, etc.)
})Relationship to Router
Router navigates whole-screen destinations and cross-type moves (screen ↔ scene ↔ native view ↔
video) at the app root. UIPager is a navigation region inside a screen. They coexist, and the
choice from any scope is simply the verb: UIPager.push opens within the region (bars stay),
Router.push opens on top of everything (covers the whole screen the pager lives in). See
screen-router.md. UIPager is not itself a destination — you don't open()
it or Router.push it; embed it in a screen.