Screens & navigation
Step 5 of the UI track. So far the tracker has been one screen opened with screen.open(). Today it becomes an app: "Today" and "History" tabs at the bottom, a habit screen on top of the list, a back button — on-screen and hardware. Three tools: Router for the screen stack, onOpen / onClose for the lifecycle, and UITabs — the ready-made tab shell.
The screen stack — Router
Router holds a stack of screens. Router.init(home) instead of home.open() — and from then on Router.push(screen) puts a screen on top, Router.pop() takes it off. Screens below the top stay in memory with all their state — they just aren't drawn; a screen that leaves the stack is destroyed.
A screen with data is a factory function: HabitScreen(h) builds a fresh screen for a specific habit at the moment of navigation:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
type Habit = { title: string; time: string; streak: number }
const habits: Habit[] = [
{ title: "Morning run", time: "07:30", streak: 12 },
{ title: "Read 20 pages", time: "13:00", streak: 5 },
{ title: "Stretching", time: "20:30", streak: 31 },
]
const BackButton = () =>
UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => Router.pop())
const HabitScreen = (h: Habit) =>
UIScreen(
BackButton(),
UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText(`Every day at ${h.time}`).style({ color: colors.muted, fontSize: 15 }),
UIColumn(
UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }),
UIText("day streak").style({ color: colors.muted, fontSize: 13 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onBackPressed(() => Router.pop()) // the hardware back on Android
const HabitRow = (h: Habit) =>
UIButton(
UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIText("›").style({ color: colors.muted, fontSize: 22 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
.onClick(() => Router.push(HabitScreen(h)))
const home = UIScreen(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(habits.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
Router.init(home)Router.init(home)is called once, in the entry file.Router.push/Router.popare global: call them from any file, no router reference to thread around.pop()removes one screen by default;pop(-2)two,pop(0)all the way to the first. Popped screens are destroyed, but theUIScreenobject stays usable — you can push it again.Router.replace(screen)swaps the top screen without growing the stack — that's "sign in → home". The transition is configurable:theme({ replaceTransition: "fade" })or a per-call option.- The Android back button is
screen.onBackPressed(cb). The press resolves down a chain: an open widget → the current screen → a drilled-in tab → the router → the globalapp.onBackPressed. If nobody handles it, the press is ignored — the app doesn't exit. - The on-screen "back" is an ordinary button. A system-provided one can be enabled:
Router.init(home, { showDefaultBackButton: true }).
Screen lifecycle
A screen has two events: onOpen — it became active, onClose — it stopped being active. They fire on every activation, not just the first: a push on top fires the lower screen's onClose, a pop back fires its onOpen again. One rule, no exceptions: whatever starts in onOpen stops in onClose — loops, intervals, sockets — or it keeps running behind the next screen.
A habit screen with a focus timer — the minute ticks only while it's open:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const TimerScreen = (title: string, seconds: number) => {
const left = signal(seconds)
let timer: number | null = null
return UIScreen(
UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => Router.pop()),
UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText(() => `${left.value} s`).style({ fontSize: 64, fontWeight: 800, mt: 24 }),
UIText(() => (left.value > 0 ? "The timer runs while the screen is open" : "Done!"))
.style({ color: colors.muted, fontSize: 15 }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onOpen(() => {
timer = setInterval(() => { if (left.value > 0) left.value-- }, 1000)
})
.onClose(() => {
if (timer !== null) { clearInterval(timer); timer = null }
})
.onBackPressed(() => Router.pop())
}
const Row = (title: string, seconds: number) =>
UIButton(
UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIText(`${seconds} s`).style({ color: colors.muted, fontSize: 13 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
.onClick(() => Router.push(TimerScreen(title, seconds)))
const home = UIScreen(
UIText("Focus").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(Row("Stretching", 60), Row("Meditation", 120), Row("Plank", 45)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
Router.init(home)Open a timer, wait a couple of seconds, go back and open it again — it starts over with a fresh screen, and the old interval was stopped in onClose. Note that left and timer are declared inside the factory: every opened screen has its own state, and it dies with it.
The same pattern goes for setLoop in animations, setInterval polling a server, a WebSocket in a chat. The full rules are in the SDK conventions.
Tabs — UITabs
A bottom tab bar is the most common shell of a mobile app, and in LeCodes it's ready-made: UITabs is a UIScreen of a UIPager (tabs that swipe natively, each with its own stack) plus a tab bar styled through theme variables. The object keys are the tab ids, in order:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })
const HabitScreen = (title: string) =>
UIScreen(
UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.pop()),
UIText(title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText("Opened inside the tab — the tab bar stayed").style({ color: colors.muted, fontSize: 15 }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onBackPressed(() => UIPager.pop())
const Row = (title: string) =>
UIButton(UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }), UIText("›").style({ color: colors.muted, fontSize: 22 }))
.style({ bgColor: colors.card, borderRadius: 16, p: 14, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.push(HabitScreen(title))) // push INSIDE the current tab
const today = UIScreen(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(Row("Morning run"), Row("Read 20 pages"), Row("Stretching")).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
const history = UIScreen(
UIText("History").style({ fontSize: 32, fontWeight: 800 }),
UIText("The check-in list goes here").style({ color: colors.muted, fontSize: 15 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
const tabs = UITabs({
today: { label: "Today", icon: assetIcon("lucide:circle-check"), screen: today },
history: { label: "History", icon: assetIcon("lucide:calendar"), screen: history },
})
tabs.badge("history", 3)
Router.init(tabs)Tap a habit — the screen opens inside the tab and the tab bar stays; swiping between tabs works at each tab's root. What to remember:
- Two navigation verbs.
UIPager.push(screen)opens a screen in the current tab (bar visible, the edge back-swipe is native).Router.push(screen)opens on top of everything, bar included: sign-in, a full-screen player. Both are global — from any screen, no references. - The bar is themed:
primaryColorfor the active tab,mutedColorfor inactive ones,tabbarBg/tabbarBorderfor the surface and hairline. An unthemed app gets dark defaults. assetIcon("lucide:calendar")is a compile macro: an icon from the registry is inlined into the bundle at build time; the id is a string literal.tabs.select("history"),tabs.tab,tabs.onSelect((id) => …),tabs.badge(id, 3 | true | 0)— programmatic control. Each tab remembers its stack: leave a tab three screens deep, come back — you're exactly there.- Need a custom bar — drop one level down:
UIPager(a, b)plus your ownUIRowof buttons;pager.onSelectkeeps the highlight in sync on swipes.
How this splits into files
One file per screen, the entry point assembling the shell — the standard shape of a LeCodes app:
// screens/today.ts
import { colors } from "../theme"
export const todayScreen = UIScreen(/* … */).style({ bgColor: colors.bg })
// screens/habit.ts
export const HabitScreen = (h: Habit) => UIScreen(/* … */).onBackPressed(() => UIPager.pop())
// main.ts
import { todayScreen } from "./screens/today"
import { historyScreen } from "./screens/history"
Router.init(UITabs({
today: { label: "Today", icon: assetIcon("lucide:circle-check"), screen: todayScreen },
history: { label: "History", icon: assetIcon("lucide:calendar"), screen: historyScreen },
}))A screen that opens with data is an exported factory; a tab screen is a ready object. Neither imports a router: Router and UIPager are globals.
The whole app
The tracker with two tabs, a habit screen and a live badge — how many habits are left today:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff", primaryColor: colors.accent, mutedColor: colors.muted, tabbarBg: "#15151a", tabbarBorder: "#26262e" })
const type = {
h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}
type Habit = { id: number; title: string; time: string; color: string; done: boolean; streak: number }
const habits = signal<Habit[]>([
{ id: 1, title: "Morning run", time: "07:30", color: "#34c759", done: true, streak: 12 },
{ id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: true, streak: 5 },
{ id: 3, title: "Practice piano", time: "18:00", color: "#5e9eff", done: false, streak: 2 },
{ id: 4, title: "Stretching", time: "20:30", color: "#af6bff", done: false, streak: 31 },
])
const leftCount = computed(() => habits.value.filter((h) => !h.done).length)
const toggle = (h: Habit) => {
habits.value = habits.value.map((x) => (x === h ? { ...x, done: !x.done } : x))
}
const BackButton = () =>
UIButton(UIText("← Back").style({ color: colors.muted, fontSize: 15 }))
.style({ alignSelf: "flex-start", py: 6, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.pop())
const HabitScreen = (h: Habit) =>
UIScreen(
BackButton(),
UIText(h.title).style({ fontSize: 28, fontWeight: 800, mt: 8 }),
UIText(`Every day at ${h.time}`).style({ ...type.small, fontSize: 15 }),
UIColumn(UIText(`${h.streak}`).style({ fontSize: 40, fontWeight: 800 }), UIText("day streak").style(type.small))
.style({ bgColor: colors.card, borderRadius: 16, p: 16, mt: 16, alignItems: "center" }),
UIButton(UIText(() => (habits.value.find((x) => x.id === h.id)?.done ? "Unmark" : "Mark done")).style({ ...type.body, color: "#ffffff" }))
.style({ bgColor: h.color, borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
.onClick(() => { const cur = habits.value.find((x) => x.id === h.id); if (cur) toggle(cur) }),
)
.style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
.onBackPressed(() => UIPager.pop())
const HabitRow = (h: Habit) =>
UIButton(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color, opacity: h.done ? 1 : 0.35 }),
UIColumn(UIText(h.title).style(type.body), UIText(h.done ? "done" : h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
UIText("›").style({ color: colors.muted, fontSize: 22 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, $pressed: { opacity: 0.6 } })
.onClick(() => UIPager.push(HabitScreen(h)))
const today = UIScreen(
UIText("Today").style(type.h1),
UIText(() => `${leftCount.value} of ${habits.value.length} left`).style({ ...type.small, fontSize: 15 }),
UIScrollable(() => habits.value.map(HabitRow)).style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
const history = UIScreen(
UIText("History").style(type.h1),
UIColumn(
[0, 1, 2, 3, 4, 5, 6].map((d) =>
UIRow(
UIText(date().subtract(d, "day").format("dddd, D MMMM")).style({ ...type.body, flexGrow: 1 }),
UIText(`${4 - (d % 3)} / 4`).style(type.small),
).style({ bgColor: colors.card, borderRadius: 16, p: 14 }),
),
).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
const tabs = UITabs({
today: { label: "Today", icon: assetIcon("lucide:circle-check"), screen: today },
history: { label: "History", icon: assetIcon("lucide:calendar"), screen: history },
})
effect(() => tabs.badge("today", leftCount.value)) // the badge follows the state
Router.init(tabs)Two details that are easy to miss. First, effect — the first time in the track: a tab badge isn't a binding inside an element but a method call, so it has to be repeated by hand on every state change — exactly what effect does. Second, the habit screen receives a snapshot h at the moment it opens, so the "Mark done" button looks up the current record by id in the signal — that way the screen doesn't go stale if the habit was checked off from the list.
What you learned
Router.initonce in the entry file;Router.push/pop/replaceare global. Screens on the stack keep their state, popped ones are destroyed.- A screen with data is a factory function; its state is declared inside it.
onOpen/onClosefire on every activation; whatever starts inonOpenstops inonClose.UITabsis the ready shell: tabs with their own stacks, a bar made of theme variables,badge.UIPager.pushinside a tab,Router.pushover everything.onBackPressedon a screen for the hardware back;assetIcon("pack:name")for registry icons at build time.
Reference: Screens & Router · UIPager & UITabs · Conventions — lifecycle · Content: icons.
Next: forms & keyboard — the "New habit" screen: inputs, validation, the focus chain and everything you need to know about the keyboard.