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

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

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

Switch tabs from any screen with UITabs.select(id), react to a change with onSelect, keep a count on a tab with badge:

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

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

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

Note

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:

TypeScript
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)
Note

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.

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

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

Note

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.

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

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

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

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

TypeScript
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 ]
}
TypeScript
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