LeCodesdocs

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.

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

TypeScript
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

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

TypeScript
UIScreen(
  UIPager(HomeTab(), ProfileTab()).style({ flexGrow: 1 }),
  tabBar,
)

Tabs

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

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

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

TypeScript
UIPager.current        // the pager whose screen is visible, or null
UIPager.push(screen)   // → current.push(screen)
UIPager.pop()          // → current.pop()
UIPager.popToRoot()
TypeScript
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:

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

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