UIPager
UIPager is navigation inside a screen: pages side by side that swipe, each with its own stack of
screens; UITabs is the standard bottom-tab shell built on it, a stack per tab. Router is the
app's stack of whole screens, over everything (screen-router.md); a carousel of
slides inside a screen is a snapping UIScrollable (containers.md).
- give the app a bottom tab bar with icons (
UITabs) - open a detail screen inside a tab and keep the tab bar
- swipe left and right between pages
- switch tabs from code and react when the tab changes
- show a badge with a count on a tab
- go back inside a tab — the system back, a back button (
UIPager.pop) - keep a header on screen while the screens under it change
- build your own tab bar over a pager
- draw the tab bar in design mockups (
defineTabs)
Platforms: every host; swiping between tabs, the slide of select(i, true) and the back press
only on iOS, Android and the web — not on desktop, in the web player or in headless tests.
At a glance
const post = (n: number) => UIScreen(
UIText(`Post ${n}`).style({ fontSize: 22, color: "white" }),
).style({ bgColor: "#101114", p: 24 })
const feed = UIScreen(
UIButton(UIText("Open post 1").style({ color: "white" }))
.style({ height: 44, bgColor: "#2b3550", borderRadius: 8 })
.onClick(() => UIPager.push(post(1))), // from inside a tab's screen: the bar stays
).style({ bgColor: "#101114", p: 24, pt: "safe-top" })
const profile = UIScreen(UIText("Profile").style({ color: "white" }))
.style({ bgColor: "#101114", p: 24, pt: "safe-top" })
const tabs = UITabs({
feed: { label: "Feed", icon: assetIcon("lucide:house"), screen: feed },
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: profile },
})
Router.init(tabs)Bottom tab bar: UITabs
For an app with a bottom tab bar — a few main sections, each a tab with its own stack of screens —
use UITabs. It is a UIScreen of [ UIPager (flexGrow: 1), themed bar ] with the selection
sync, the active highlight, the safe-area padding and badges built in. Keys are tab ids, in tab
order; the buttons are named tab-<id> and the bar tabbar (flow tests tap them by name):
UITabs(tabs: Record<string, { label: string, icon?: ImageSource, screen: UIScreen }>): UITabs
Router.init(tabs) // UITabs IS a UIScreen / Presentable
tabs.select("profile") // switch tabs (instant; `true` slides); an unknown id warns
tabs.tab // active tab id (getter)
tabs.onSelect((id, i) => console.log(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)
tabs.bar // the bar row
UITabs.current // the tabs whose page is visible, or null — from any screen, no reference threading
UITabs.select("profile") // UITabs.current.select(id); warns when no tabs are on screenSwitch tabs from any screen with UITabs.select(id), react to a change with onSelect, keep a
count on a tab with badge:
const inbox = UIScreen(UIText("Inbox").style({ color: "white" })).style({ p: 24, pt: "safe-top" })
const home = UIScreen(
UIButton(UIText("Go to inbox").style({ color: "white" }))
.style({ height: 44, bgColor: "#2b3550", borderRadius: 8 })
.onClick(() => UITabs.select("inbox")), // from any screen, no reference needed
).style({ p: 24, pt: "safe-top" })
const tabs = UITabs({
home: { label: "Home", icon: assetIcon("lucide:house"), screen: home },
inbox: { label: "Inbox", icon: assetIcon("lucide:inbox"), screen: inbox },
})
tabs.badge("inbox", 3) // a count pill; `true` = a dot
tabs.onSelect((id) => { if (id === "inbox") tabs.badge("inbox", 0) }) // 0 clears it
Router.init(tabs)Inside a tab, UIPager.push(detail) from a screen the tabs show keeps the bar; Router.push(...)
opens above the whole shell, bar covered (below).
Tab bar colors and style
The bar is colored by the theme's roles and has no keys of its own: active
accent, inactive textMuted, background surface (without it: bg), top line border; the
shell is a screen, so its background is bg — an app's one theme({...}) call colors the bar
with everything else (and the defineTabs bar the same
way). With no theme the bar is dark. To give ONE bar other colors, scope it:
tabs.bar.theme({ accent: "#FF9500", surface: "#000" }). The badge is red. The active button
also carries the active style class (cascades to its content). tabs.bar.style({...}) restyles
the bar's box; for a fully custom bar, drop down to UIPager + your own row
(below).
Open a screen inside a tab: UIPager.push
To open a detail screen inside the current tab — the tab bar stays — call UIPager.push(screen)
from any screen the pager shows; Router.push(screen) opens over the whole shell and covers the
bar. UIPager is a global, so no reference is threaded into child screens: the ambient calls act
on the pager that owns the visible screen.
UIPager.current // the pager whose screen is visible, or null
UIPager.push(screen, opts?) // → current.push(screen, opts)
UIPager.pop(opts?) // → current.pop(opts)
UIPager.popToRoot(opts?)const settings = UIScreen(UIText("Settings").style({ color: "white" })).style({ p: 24, pt: "safe-top" })
const order = UIScreen(
UIButton(UIText("Back").style({ color: "white" })).onClick(() => UIPager.pop()),
UIText("Order 1").style({ fontSize: 22, color: "white" }),
).style({ p: 24, pt: "safe-top", gap: 16 })
const orders = UIScreen(
UIButton(UIText("Open order").style({ color: "white" })).onClick(() => UIPager.push(order)), // the bar stays
UIButton(UIText("Settings").style({ color: "white" })).onClick(() => Router.push(settings)), // covers the bar
).style({ p: 24, pt: "safe-top", gap: 16 })
Router.init(UITabs({
orders: { label: "Orders", icon: assetIcon("lucide:package"), screen: orders },
profile: { label: "Profile", icon: assetIcon("lucide:user"), screen: UIScreen(UIText("Profile")) },
}))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.
A tab's stack: push, pop, replace
Each tab is a stack of screens: push opens a screen on top of the CURRENT tab, pop unwinds it,
replace swaps its top; the other tabs are untouched. A push or a pop plays a screen transition
inside the pager's box.
pager.push(screen, opts?: { transition, popTransition }) // a new top on the CURRENT tab
pager.pop(opts?: { transition }) // pop the current tab's top (no-op at the tab root)
pager.popToRoot(opts?: { transition }) // unwind the current tab to its root
pager.replace(screen, opts?: { transition, popTransition }) // swap the current tab's top (at depth 1: its root)
pager.stack // UIScreen[] of the current tab, root → top (a copy)
pager.depth // 1 = only the root (getter)
pager.onChange(depth => console.log("depth", depth)) // push, pop, replace, the back button / swipe
pager.children // every page, tab-major (each tab's stack, root → top)Transitions are the Router's (screen-router.md): push plays
"push" unless one is named, and the screen remembers its way back (popTransition, or the
transition it came with, the other way round); pop and popToRoot play that way back unless a
transition is named — popToRoot shows only the top leaving, the screens between are never shown;
replace plays "fade" unless one is named (the theme's replaceTransition is the Router's
alone) and keeps the way back of the place in the stack.
A page that leaves its tab's stack is detached when its transition lands (its onClose fires
first). screen.keepAlive() asks the pager to keep that page's native views when it drops it —
scroll position, input text — so pushing the same screen object again restores it in place; free
such a page with destroy().
Back inside tabs and pages
A screen's own back press, with no pager in it, is screen.onBack
(screen-router.md); this section
is what back does inside a pager. The system back (Android's button or gesture) pops the innermost
drilled-in tab by itself — after the shown widgets' and the current screen's own onBack, before
the router (the chain). A page's
own onBack is not asked. On iOS the system's edge swipe pops a drilled-in tab (a pager that
doesn't reach the screen's left edge has none). At a tab's root, back goes on to the router.
desktop, the web player and headless tests have no back input — give a drilled-in
screen a button that calls UIPager.pop(). On the web the browser's Back reaches a tab only while
the router is above its root: a push inside a tab adds no entry to the browser's history, so at
the router's root Back leaves the page.
A screen that holds a pager and sets its own onBack takes the press first — pop the tab
yourself, then fall through to your own handling:
const post = UIScreen(UIText("Post").style({ color: "white" })).style({ p: 24, pt: "safe-top" })
const feed = UIScreen(
UIButton(UIText("Open post").style({ color: "white" })).onClick(() => UIPager.push(post)),
).style({ p: 24, pt: "safe-top" })
const tabs = UITabs({
feed: { label: "Feed", screen: feed },
profile: { label: "Profile", screen: UIScreen(UIText("Profile").style({ color: "white" })) },
})
tabs.onBack(() => { // the screen that holds the pager takes the press
const pager = UIPager.current
if (pager && pager.depth > 1) { pager.pop(); return } // pop the drilled-in tab yourself
toast("Nothing to go back to") // then your own behavior (confirm, exit, …)
})
Router.init(tabs)on iOS a screen with its own onBack turns the pager's edge swipe off: iOS offers that
swipe only when a back would pop the tab itself. For a policy at the end of the chain (press back
again to exit), app.onBack needs no screen handler: on
Android it runs once every pager and the router have nothing left to pop.
Swipe left and right between pages and switch tabs: select
A pager's tabs sit side by side: the user swipes left and right between them, and select(i)
switches from code.
pager.select(2) // instant switch (no-op if out of range or already selected)
pager.select(2, true) // animated, direction-aware slide
pager.index // the selected tab (getter)
pager.onSelect(i => console.log("tab", i)) // 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 true as the second argument for the
sliding switch of top-tab UIs (the Material feel). Swiping is always animated — it's a physical
drag.
const step = signal(0)
const page = (title: string) => UIScreen(
UIText(title).style({ fontSize: 28, color: "white" }),
).style({ justifyContent: "center", alignItems: "center" })
const pager = UIPager(page("Welcome"), page("Sync your notes"), page("All set"))
.style({ flexGrow: 1 })
.onSelect((i) => { step.value = i }) // a settled swipe, or select()
Router.init(UIScreen(
pager,
UIButton(UIText(() => `Next (${step.value + 1} / 3)`).style({ color: "white" }))
.style({ height: 48 })
.onClick(() => pager.select((step.value + 1) % 3, true)), // true = slide
).style({ pb: "safe-bottom" }))Tabs keep their stacks: select away from a tab that's three screens deep, come 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 back-swipe; paging re-enables when the tab is
back at its root. There is nothing to configure.
swiping between tabs and the slide of select(i, true) are on iOS, Android and the
web. On the desktop, in the web player and in headless tests a tab changes at once — no swipe, no
slide; a push / pop inside a tab plays its transition everywhere.
Create a pager: UIPager(...)
UIPager(...) takes screens: several become tabs side by side, one becomes a plain navigation
stack (there is no separate stack element) — a part of a screen whose content changes under a
header or a bar that stays.
UIPager(root) // one tab = a plain stack rooted at `root`
UIPager(a, b, c) // three tabs, side by side (a is selected)
UIPager(home, isAdmin && adminTab) // an array argument is flattened, falsy entries are skipped
pager.setContent(tabs) // replace the tabs (below); a function of signals = reactive tabsChildren are UIScreens only, laid out to the pager's box; to host a plain element, wrap it:
UIPager(UIScreen(el)). Style via chained .style({...}). 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 a tab bar).
One page per pager is open: the selected tab's top, while the pager's own screen is shown. A
page's onOpen / onClose fire as it becomes / stops being that page — a push, a pop, a tab
switch. setContent(tabs) replaces the tabs: every tab becomes a fresh one-screen stack (pushed
screens are dropped), the selection is clamped into range, and screens that were already tabs keep
their state.
A header that stays while the screens under it change — a one-tab pager, the header's back button shown by the stack's depth:
const item = UIScreen(UIText("Item 1").style({ fontSize: 22, color: "white" }))
const list = UIScreen(
UIButton(UIText("Open item 1").style({ color: "white" })).onClick(() => UIPager.push(item)),
)
const back = UIButton(UIText("Back").style({ color: "white" }))
.style({ display: "none" })
.onClick(() => stack.pop())
const stack = UIPager(list) // one tab: a plain navigation stack
.style({ flexGrow: 1 })
.onChange((depth) => { back.style.display = depth > 1 ? "flex" : "none" })
Router.init(UIScreen(
UIRow(back).style({ height: 56 }), // the header stays while the stack changes
stack,
).style({ pt: "safe-top" }))Your own tab bar over UIPager
UITabs ships the standard bottom bar. For a bar of your own over a raw
pager, compose one and drive it both ways — a tap selects the tab, onSelect moves the highlight
(on a swipe too):
const home = UIScreen(UIText("Home feed").style({ color: "white" })).style({ p: 24, pt: "safe-top" })
const profile = UIScreen(UIText("Your profile").style({ color: "white" })).style({ p: 24, pt: "safe-top" })
const pager = UIPager(home, profile)
const tab = (label: string, i: number) =>
UIButton(UIText(label).style({ color: "#8a919e", $active: { color: "white" } }))
.style({ flex: 1, height: 56 })
.onClick(() => pager.select(i))
const buttons = [tab("Home", 0), tab("Profile", 1)]
buttons[0].class.active = true
pager.onSelect(i => buttons.forEach((b, j) => { b.class.active = i === j })) // swipes move the highlight too
Router.init(UIScreen(pager.style({ flexGrow: 1 }), UIRow(buttons)))Tab bar for design mockups: defineTabs
defineTabs declares the tab bar without screens — the design-stage twin of UITabs (the same
keys, the same tab-<id> / tabbar names, the same theme roles) for mockups where every screen is
its own file and mounts the bar with a literal tab id (active must be a declared one). The bar
only draws: its buttons have no click handler. The built app passes the same keys, plus each tab's
root screen, to UITabs.
defineTabs(tabs: Record<string, { label: string, icon?: ImageSource }>): {
ids: string[] // the tab ids, in declaration order
bar(active: id): UIRow // the bar alone, `active` highlighted
screen(active: id, ...children): UIScreen // [ a padded column of children, the bar ]
}const mainTabs = defineTabs({
home: { label: "Home", icon: assetIcon("lucide:house") },
profile: { label: "Profile", icon: assetIcon("lucide:user") },
})
mainTabs.screen("profile",
UIText("Profile").style({ fontSize: 24, fontWeight: 700, color: "white" }),
).open()screen() puts the children in a column (flexGrow: 1, p: 16, pt: "safe-top") above the bar,
on the theme's bg.
UIPager vs 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
verb decides: UIPager.push opens within the region (the bars stay), Router.push opens on
top of everything (it covers the screen the pager lives in). UIPager is not itself a
destination — you don't open() it or Router.push it; embed it in a screen.
See also
- Screens & Router — the app's stack of screens, transitions, the back chain
- Containers § Carousel — slides that swipe inside a screen
- Theme — the roles the tab bar reads