UIScreen & Router
UIScreen is one whole screen of the app; Router is the stack of screens — push opens one on
top, pop and the back button go back, replace swaps the top. Pages side by side that the user
swipes between are a UIPager, and the standard bottom-tab shell with a stack per tab is UITabs
(both pager.md).
- open a details screen and come back
- handle the Android back button or the iOS back swipe on a screen (
onBack) - start a timer when a screen opens and stop it when the screen closes (
onOpen/onClose) - slide or fade to the next screen with a transition of your own
- replace the login screen with the home screen so back does not return to it
- go back to the first screen from deep in the stack (
Router.pop(0)) - keep a header fixed while the content under it scrolls
- know which screen is shown, for a shared tab bar or analytics
- give a screen an address in the browser on the web (
url())
Platforms: every host; the back press only on Android (the back button and gesture), iOS (the edge swipe) and the web (the browser's Back) — not on desktop or in the web player.
At a glance
const detail = UIScreen(
UIButton(UIText("‹ Back").style({ color: "white" }))
.style({ alignSelf: "flex-start", p: 8 })
.onClick(() => Router.pop()), // the system back pops by itself; this is for every host
UIText("Detail").style({ fontSize: 24, fontWeight: 700, color: "white" }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })
const home = UIScreen(
UIText("Home").style({ fontSize: 24, fontWeight: 700, color: "white" }),
UIButton(UIText("Open detail").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
.onClick(() => Router.push(detail)),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })
Router.init(home)UIScreen
A single-screen app builds one UIScreen and calls open(); an app with several screens hands
them to Router. A screen fills its slot — the device, or a pager's box for a page — and lays its
children out as a UIColumn.
UIScreen(...children: (UINodeChild | UINodeChild[])[]): UIScreen
screen.open(opts?: { transition }) // show this screen directly — it replaces what is shown; an
// active Router is suspended until Router.restore()
screen.close(opts?: { transition }) // take it off screen if it is the one shown (nothing replaces
// it); a screen that is not shown is left alone
screen.onTouchStart(ev => …) // a touch on the screen that no button inside it takes
screen.keepAlive(enabled?: boolean) // a pager keeps this page's views when it drops it — pager.mdFull container surface: .append / .insert / .remove / .setContent, .style,
.animateTo / .animateFrom, .onLayout. onTouchStart supports ev.track() like a button —
see Pointer events.
a screen always fills its slot — its style has no sizing props (width, height,
flexGrow, position are type errors there). Style its padding, background, and layout of
children. Its background is the theme's bg (theme.md); with no theme it has none
(the host's backdrop shows through) — set bgColor and text colors, or a theme.
Start and stop work when a screen opens and closes: onOpen / onClose
A timer, a poll or a socket that serves one screen starts in the screen's onOpen and stops in its
onClose — or it keeps running behind the other screens.
screen.onOpen(cb: () => void): this // the screen became the one shown
screen.onClose(cb: () => void): this // it stopped being the one shown — covered, popped, replaced, closedlet seconds = 0
let timer: number | null = null
const label = UIText("0 s").style({ fontSize: 32, color: "white" })
const stopwatch = UIScreen(label)
.style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
.onOpen(() => { // shown: tick every second
timer = setInterval(() => { label.text = `${++seconds} s` }, 1000)
})
.onClose(() => { // covered, popped or replaced: stop
if (timer !== null) { clearInterval(timer); timer = null }
})
Router.init(stopwatch)They fire on every activation, not just the first: Router.init and open() fire onOpen,
pushing away from a screen fires its onClose, popping back to it fires onOpen again — the
stopwatch stands still while another screen covers it and goes on when it comes back. They fire
when the navigation is made — at the start of its transition, not when it lands. Each call adds a
handler. See Conventions § Lifecycle.
animations running on a screen's elements (animateTo / animateFrom, loops
included) are cancelled when the screen is covered or leaves; restart a looping one in onOpen.
Back button and back-swipe on a screen: onBack
To handle the Android back button (and the iOS back swipe) on a screen yourself — ask before
discarding an edit, close something inside the screen — give the screen an onBack. Without one,
back pops the router by itself; with one, the screen takes the press and nothing pops unless the
handler calls Router.pop() (or UIPager.pop()).
screen.onBack(cb: () => void): this // the back press while this screen is shown; a second call replaces the firstlet edited = false
let warnedAt = 0
const editor = UIScreen(
UIInput().style({ placeholder: "Write a note", color: "white" }).onChange(() => { edited = true }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
.onBack(() => {
if (!edited || Date.now() - warnedAt < 2000) { Router.pop(); return } // nothing to lose, or a second press
warnedAt = Date.now()
toast("Unsaved note: press back again to discard it")
})
Router.init(UIScreen(
UIButton(UIText("New note").style({ color: "white" })).onClick(() => Router.push(editor)),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" }))A back press runs one chain; the first step that takes it wins:
- the shown widgets that have an
onBack, the last shown first (UIModal,UIPopoverandUIBottomSheethave one: they close — withdismissible(false)they keep the press and stay); - the current destination's
onBack(a screen, a scene, a native view, a video); - the innermost pager whose current tab is drilled in pops it;
- the router pops;
app.onBack, the app-wide fallback when no screen takes the back press — the place for app-wide policy such as press-back-again-to-exit (app.md).
If nothing takes the press, it is ignored — the app does not exit.
Android runs the whole chain on its back button and back gesture. iOS has no back
button: the system's edge swipe is offered only when a screen would come back — a router pop, a
pushed screen's own onBack, a drilled-in pager tab — and the chain runs when the finger lifts
(the swipe is the animation; no transition plays). So on iOS a widget's onBack, the root
screen's and app.onBack never fire, and nothing swipes back from a scene, a native view or a
video: give those a back button of their own. On the web the browser's Back runs the chain while
the router is above its root, and Escape runs the onBack of a shown widget (step 1: a modal or
a sheet closes). Desktop, the web player and headless tests have no back press: put a visible
back button on a pushed screen.
Keep a header fixed over scrolling content: one UIScrollable body
A screen never scrolls: it is a fixed layout root, with no scroll position, no scroll events
and no pull-to-refresh. To keep a header (or a tab bar) in place while the content moves, put the
fixed parts first and one scrollable body with flexGrow: 1 under them:
const rows = Array.from({ length: 40 }, (_, i) => UIText(`Row ${i + 1}`).style({ fontSize: 18, color: "white" }))
const profile = UIScreen(
UIRow(UIText("Profile").style({ fontSize: 20, fontWeight: 700, color: "white" }))
.style({ height: 56, alignItems: "center", px: 24 }), // the header: stays put
UIScrollable(rows) // ONE scrolling body under it
.style({ flexGrow: 1, px: 24, gap: 16 })
.onRefresh(async () => { console.log("pulled to refresh") }), // pull-to-refresh lives here too
).style({ bgColor: "black", pt: "safe-top" })
profile.open()Scroll events (onScroll / onScrollRelease / onOverscroll), pull-to-refresh (onRefresh)
and the refreshControlColor style all belong to UIScrollable — and to
UIVirtualizedList for long data.
Router
Router.init(homePage) // once, in the entry file: the stack's root, shown at once
Router.push(page, opts?: { transition, popTransition }) // push onto the stack; the page becomes the one shown
Router.replace(page, opts?: { transition, popTransition }) // swap the top — no back entry to the replaced one
Router.pop(to?: number, opts?: { transition }) // default -1 = one screen back
Router.current // the top of the router stack (getter); null before Router.init
Router.hide(opts?: { transition }) // take the router's page off screen (the stack stays)
Router.restore(opts?: { transition }) // show the stack top again, after hide() or a direct open()
Presentable.current // what's actually on screen right now (or null)A page is any Presentable: a UIScreen, a Scene / ARScene, a Scene2D, a NativeView,
a UIVideo. A destination that prepares first (ARScene: the camera permission, a warm-up)
makes push / replace return a Promise — the current page stays until it is ready, and a
rejection (permission denied, superseded) leaves the stack as it was.
Router.current is the top of the stack — it stays meaningful while the router is suspended by
a direct open() or hide(). It follows the runtime's change report, which arrives after the
navigating call returns: right after Router.push(page) it still names the previous top.
Presentable.current is set at once: the destination actually visible (a UIScreen, Scene,
…), null before the first open and after close() / hide() — UIPopover.show() attaches
to it.
Router.init(home, { showDefaultBackButton }) — the option is accepted and
ignored; no host draws a back button.
Replace a screen so back does not return to it: Router.replace
After a login, an onboarding or a splash screen, Router.replace swaps the top of the stack for
the next screen: the old one leaves the stack, so neither the back button nor Router.pop()
returns to it.
const home = UIScreen(UIText("Welcome").style({ fontSize: 24, color: "white" }))
.style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
const login = UIScreen(
UIButton(UIText("Log in").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
.onClick(() => Router.replace(home)), // home takes the login's place in the stack
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
Router.init(login)A replace plays no transition unless one is named or themed (Transitions).
Go back several screens or to the first one: Router.pop(to)
Router.pop() goes back one screen; pop(to) unwinds several in one step — a wizard or a
checkout that ends on the screen it started from:
const step = (n: number): UIScreen => UIScreen(
UIText(`Step ${n} of 3`).style({ fontSize: 24, color: "white" }),
UIButton(UIText(n < 3 ? "Next" : "Done").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
.onClick(() => n < 3 ? Router.push(step(n + 1)) : Router.pop(0)), // 0 = the first screen
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)", gap: 16 })
Router.init(step(1))Negative values of to are relative (-2 = back two screens); 0 and positive values are an
absolute index into the stack (0 = the first screen). At the root pop() does nothing. Screens
that leave the stack are detached, not destroyed (below).
Transitions
Every call that changes what is on screen takes a transition — Router.push / replace / pop / hide / restore, and open() / close() of a screen, a scene, a native view, a video:
| call | default |
|---|---|
Router.push |
"push" |
Router.replace |
"none", themeable: theme({ replaceTransition: "fade" }) — see theme.md |
Router.pop, the back button |
the way back the screen remembers (below) |
open(), close(), Router.hide(), Router.restore() |
"none" |
The names (TransitionName): "push" | "pop" | "slide-from-left" | "slide-from-right" | "slide-from-top" | "slide-from-bottom" | "zoom" | "zoom-in" | "zoom-out" | "fade" | "none".
push / pop keep each phone platform's own feel: Android's timing and curve on Android, iOS's
on every other host.
Your own. A transition (TransitionSpec) is two poses: where the incoming screen comes FROM
(enter) and where the outgoing one goes TO (exit); a side left out stays put. A pose
(TransitionPose) takes the keys of an animateTo bag a transition can move — transform,
opacity, dim (black over the screen, 0..1) — with the side's own duration (ms, default
300), delay (ms) and easing (default ease-in-out); an array is a list of keyframes. In a pose
a % in translate is a percent of the screen's own size:
const sheet: TransitionSpec = {
enter: { transform: "translateY(100%)", duration: 400, easing: "outCubic" }, // the new screen rises from below
exit: { transform: "scale(0.94)", dim: 0.3, duration: 400 }, // the old one shrinks back and darkens
}
const details = UIScreen(UIText("Details").style({ fontSize: 24, color: "white" }))
.style({ bgColor: "#1C1C1E", p: 20, pt: "max(safe-top, 24px)" })
const home = UIScreen(
UIButton(UIText("Show details").style({ color: "white" }))
.style({ bgColor: "#FF4032", borderRadius: 12, p: 16 })
.onClick(() => Router.push(details, { transition: sheet })), // back plays the mirror: it sinks again
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
Router.init(home)onTop: "exit" keeps the outgoing screen above the incoming one (default: the incoming is on top).
A pose's transform is a list of translate / scale / rotate / skew functions, the same
functions in every keyframe — anything else is ignored with a warning. A transition lasts as long
as its longer side. The option bags of open() and close() are
PresentOptions / DismissOptions: { transition?: Transition }, where Transition =
TransitionName | TransitionSpec.
The way back. A screen on the stack remembers how to leave: popTransition, or — when none is
given — the transition it came with, the other way round. Router.pop() and the back button play
it; Router.pop(to, { transition }) overrides it for one call.
Router.push(page) // back: a pop
Router.push(page, { transition: "slide-from-bottom" }) // back: it leaves downwards
Router.push(page, { transition: "zoom", popTransition: "pop" })A zoom and a fade go back with a fade. A spec of your own goes back with its mirror: the screen
that came leaves into its enter pose, the one it covered comes back from its exit pose, with
the timing and curves as written. A replace that names no popTransition keeps the way back of
the screen it replaces — it belongs to the place in the stack.
Things to know:
- The first pose is on screen in the call that navigates; the transition's clock starts on the next frame. The screen that leaves stays mounted until its transition lands.
- One transition at a time: a navigation made while one plays lands it first (the screen that was leaving is dropped at rest), then plays its own from rest.
- A screen's own
transformandopacityare not seen while a transition moves it; they are back when it lands. Put a lasting effect on a child container. - A scene, a native view and a video never move: next to one of them only the screen's side of the transition plays, above it.
- A
%intranslateis a percent of the element's own box everywhere —.style()andanimateTomeasure an element the way a transition measures the screen.
How screens live on the stack
Screens below the top stay mounted — their element trees and state survive and keep taking
edits (a text set on a covered screen is there when it comes back); they're just not rendered.
push fires the outgoing screen's onClose and the incoming one's onOpen; pop fires them the
other way round. A screen that leaves navigation — popped, replaced, closed, or a directly opened
screen something else replaced — is detached once its transition lands, never destroyed: the
element keeps its tree, typed text included, and pushing (or opening) the same object again shows
it as it was. A screen you no longer reference is freed. Router.hide() fires the top screen's
onClose; restore() fires onOpen again. Calling screen.open() while a router is active
suspends the router (its stack stays restorable).
Track which screen the user is on: the change event
To log the screens a user visits for analytics, or keep a shared tab bar in step, listen to the
router's change event — it fires with the new top of the stack:
Router.addEventListener("change", (page: Presentable) => …) // the new top of the stack
Router.removeEventListener("change", cb)const home = UIScreen(
UIButton(UIText("Settings").style({ color: "white" })).onClick(() => Router.push(settings)),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
const settings = UIScreen(
UIButton(UIText("Done").style({ color: "white" })).onClick(() => Router.pop()),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
const names = new Map<Presentable, string>([[home, "home"], [settings, "settings"]])
Router.addEventListener("change", (page) => {
console.log(`screen view: ${names.get(page)}`) // push, pop, replace — the back button's pops too
})
Router.init(home) // init itself fires no "change"It fires on every change of the router's stack — push, pop (the back button's too), replace
— and not on init, hide / restore or a direct open().
Addresses on the web — url()
On the web a screen can name its address: while it is the one shown, the browser's address bar
shows it (…/#/product/42). The value is a ROUTE — the app never sees the page's own address.
Calling url() again on a screen that is shown changes the address in place (a filter, a search);
a screen with no url() keeps the address of the screen under it.
The router and the browser's history go together with no code of the app's: Router.push adds an
entry, Router.pop goes back one, and the browser's Back runs the same chain as the back button
of a phone (an open dialog closes first, then the screen's onBack, then the router pops).
The way IN is the app's, and it is the code a deep link on a phone takes — see app § Deep links:
const ProductScreen = (id: string) => UIScreen(
UIText(`Product ${id}`).style({ fontSize: 24, color: "white" }),
).style({ bgColor: "black", p: 20, pt: "max(safe-top, 24px)" })
.url(`/product/${id}`) // shown: the address bar reads …/#/product/42
const openFromLink = (route: string) => {
const product = /^\/product\/(\w+)/.exec(route)
if (product) Router.push(ProductScreen(product[1]))
}
Router.init(UIScreen(UIText("Home").style({ color: "white" })).style({ bgColor: "black", p: 20 }))
if (app.launchUrl) openFromLink(app.launchUrl) // the page was opened (or reloaded) at a route
app.addEventListener("url", openFromLink) // the address was changed while the app runsurl() does nothing on a host without an address bar (phones, desktop) and in the web
player.
Pitfalls
// ✗ scroll methods on a screen — screens never scroll
UIScreen(list).onRefresh(load) // method doesn't exist on a screen
// ✓ scrolling and pull-to-refresh live on a UIScrollable body
UIScreen(UIScrollable(list).style({ flexGrow: 1 }).onRefresh(load))
// ✗ interval started in onOpen, never stopped — keeps running after Router.push()
screen.onOpen(() => setInterval(poll, 1000))
// ✓ stop it in onClose, which fires on every navigation away
let timer = 0
screen.onOpen(() => { timer = setInterval(poll, 1000) }).onClose(() => clearInterval(timer))See also
- UIWidget — a screen's dialogs among its children, a surface's content, the app layer above everything
- UIPager — tabs with a stack each, inside a screen
- Containers —
UIColumnlayout rules,UIScrollable - App lifecycle —
app.onBack, deep links,pause/resume - Conventions § Lifecycle
- Pointer events & gestures