Theme — app colors, fonts and dark mode
theme() keeps the app's colors and fonts in one place — the palette, the default text color and
font, any style value that should change while the app runs — as variables a style reads with
var(); calling it again restyles the running UI in place. A state of one element (selected,
pressed) is a style class (classes.md); style properties: styling.md.
- define the app's colors once and use them on every screen
- switch between light and dark mode while the app runs
- set the default text color and font of the whole app
- set the background of every screen and the colors of the tab bar
- give one card or one screen a different accent or palette (
el.theme) - change the brand color while the app runs, e.g. from the server
- set the default transition of
Router.replace - widen or narrow the page margins next to the screen edges (comfort knobs)
At a glance
The app's colors are defined once, in a small tokens module every screen imports — the palette in
one theme() call, the type scale as plain constants:
// theme.ts — define once, export the accessors
export const colors = theme({
// the roles — the SDK reads these too (screens, default text, the tab bar)
bg: "#F4F6F5", surface: "#FFFFFF", border: "#E4E8E6",
text: "#131A17", textMuted: "#606B65", accent: "#15A34A",
// the app's own keys
accentSoft: "#E7F6ED", onAccent: "#FFFFFF",
})
export const fonts = { // type scale — spread into styles: .style({ ...fonts.h2 })
h2: { fontSize: 22, fontWeight: 700 }, body: { fontSize: 16 },
small: { fontSize: 14 }, tiny: { fontSize: 12, fontWeight: 500 },
}// main.ts — any screen imports the tokens
import { colors, fonts } from './theme'
const card = UIColumn(
UIText("Total").style({ ...fonts.small, color: colors.textMuted }),
UIText("$1,240").style({ ...fonts.h2 }), // no color: the theme's `text`
).style({ bgColor: colors.surface, border: `1px solid ${colors.border}`, borderRadius: 16, p: 16 })
const darkButton = UIButton(UIText("Dark mode").style({ color: colors.onAccent }))
.style({ bgColor: colors.accent, borderRadius: 12, p: 12 })
// every style that reads a variable repaints in place, the screen and the card included
.onClick(() => theme({ bg: "#0C0E13", surface: "#161A23", text: "#EEF1F8", border: "#2A3040" }))
Router.init(UIScreen(card, darkButton).style({ p: 16, gap: 12 })) // its background: the theme's `bg`colors.surface is the string "var(--surface)" — the runtime resolves it when the style is
applied and again after every theme() call. Spacing and type scales stay plain TS constants: a
theme variable is for what should be able to change while the app runs.
The roles: the keys the SDK reads
Seven keys are read by the SDK itself. They are the core of an app's palette — use them in your own styles too, and add your own keys beside them; there is no second list to keep in step.
| Key | What it is | The SDK reads it for |
|---|---|---|
bg |
the page background | every UIScreen's background |
surface |
a raised surface — cards, bars, sheets | the UITabs bar (without it: bg) |
border |
hairlines and outlines | the tab bar's top line |
text |
the main text color | text that names no color of its own |
textMuted |
secondary text, inactive controls | the tab bar's inactive tabs |
accent |
the brand / active color | the tab bar's active tab |
fontFamily |
the app's font | text that names no fontFamily of its own |
They are also static properties on the global — theme.accent is "var(--accent)", and so on —
for code with no tokens module to import.
With no theme at all, screens have no background of their own (what the host draws under them
shows through), text is white and the tab bar is dark — set at least bg and text.
Default text color and font: text, fontFamily
To set the color and the font of all the app's text at once, give the theme text and
fontFamily: a UIText or an input that names no color / fontFamily of its own takes them,
instead of a color on every label.
theme({ text: "#131A17", textMuted: "#606B65", fontFamily: font("manrope") })
Router.init(UIScreen(
UIText("Every label here is #131A17, in Manrope"),
UIText("A color of its own wins").style({ color: theme.textMuted }),
UIColumn(UIText("A scope restarts the default")).theme({ text: "#15A34A" }), // this subtree only
UIInput().style({ placeholder: "Typed text is #131A17 as well" }),
).style({ p: 16, gap: 12 }))A color / fontFamily on the element wins; el.theme({ text, fontFamily }) restarts the default
for one subtree (below).
font("manrope") installs a registry font and returns its family name (fonts.md).
Screen background and tab bar colors: bg, surface, accent
A UIScreen's default background is bg (.style({ bgColor }) on a screen wins), and the
UITabs bar reads surface, border, accent and textMuted — a themed app gets a tab bar in
its own colors with no key of the bar's (pager.md).
theme({ bg: "#F4F6F5", surface: "#FFFFFF", border: "#E4E8E6",
text: "#131A17", textMuted: "#606B65", accent: "#15A34A" })
const tabs = UITabs({
home: { label: "Home", screen: UIScreen(UIText("Home")) }, // background: bg
settings: { label: "Settings", screen: UIScreen(UIText("Settings")) },
}) // the bar: surface, a border line, accent / textMuted labels
Router.init(tabs)Kit components read the roles and nothing else. To give one bar other colors, give it a
scope:
tabs.bar.theme({ accent: "#FF9500" }).
Define the app's colors in one place: theme(values)
theme(values) defines the app's colors, fonts and lengths as variables — the roles and the app's
own keys in the same call — and merges them into the one app-wide table:
theme(values): accessors // MERGES into the live table; keys not mentioned keep their values- Strings pass through as written — colors (parsed where they are used, like any style
color), font names, whole expressions (
"max(safe-top, 24px)"), another variable ("var(--accent)"). - Numbers are lengths (logical px) — so a theme color is always a string (
"#0A84FF","red"), never a packed number. nullremoves a key (comfort knobs reset to their built-in defaults).- Env names (
safe-top…,vw/vh/vmin/vmax) are host-owned facts, never theme keys — writes are skipped with a console warning. - One non-style key:
replaceTransition.
Every call returns accessors for the keys it defined (colors.accent === "var(--accent)"); the
returned object is all a tokens module needs to export. A call made before or after screens are
built makes no difference: a style that reads a variable follows every write.
Use a variable in a style: var(--name)
Any UI style value can read a theme variable — through the accessor, as the raw var() string,
with a fallback, or inside calc() / min() / max():
const colors = theme({ accent: "#15A34A" })
Router.init(UIScreen(
UIText("Accessor").style({ color: colors.accent }), // colors.accent === "var(--accent)"
UIText("Raw string").style({ color: "var(--accent)" }), // the same thing
UIText("Fallback").style({ color: "var(--brand, #0A84FF)" }), // #0A84FF while `brand` is unset
UIColumn(UIText("Indented")).style({ pl: "calc(var(--comfort-left) * 2)" }), // 32: twice the knob
).style({ p: 16, gap: 12 }))var() works in UI style values and nowhere else — see Pitfalls for what that
excludes. A var() for a key that was never defined resolves to its fallback, or to nothing.
Switch between light and dark mode
To switch the app to dark mode and back, call theme() again with the same keys: every style that
reads a variable — the screens, the default text, the tab bar, your own styles — repaints in
place, with no rebuild and no call site to forget. Keep the user's choice in a stored signal so it
survives a restart:
const light = { bg: "#F4F6F5", surface: "#FFFFFF", text: "#131A17", border: "#E4E8E6" }
const dark = { bg: "#0C0E13", surface: "#161A23", text: "#EEF1F8", border: "#2A3040" }
const isDark = signal(false).stored("darkMode") // false on the first launch
effect(() => { theme(isDark.value ? dark : light) }) // now, and at every change
const toggle = UIButton(UIText(() => isDark.value ? "Light mode" : "Dark mode"))
.style({ bgColor: "var(--surface)", border: "1px solid var(--border)", borderRadius: 12, p: 12 })
.onClick(() => { isDark.value = !isDark.value })
Router.init(UIScreen(toggle).style({ p: 16 }))the SDK does not report the phone's own light / dark setting — the app starts in its own palette; offer the switch in the app.
A brand color from the server
A value decided at runtime — a brand color from the server, an accent the user picks — is a
theme() call with the keys it changes; every style that says var(--accent) follows, the tab bar
and the screens already open included:
const DEFAULT_BRAND = { accent: "#15A34A", accentSoft: "#E7F6ED" }
theme(DEFAULT_BRAND)
const loadBrand = async () => {
const res = await fetch("https://api.example.com/brand") // { "accent": "#…", "accentSoft": "#…" }
if (res.status === 200) theme(res.json<{ accent: string, accentSoft: string }>())
}
loadBrand()
Router.init(UIScreen(
UIText("Welcome").style({ color: "var(--accent)", bgColor: "var(--accentSoft)", p: 12 }),
).style({ p: 16 }))A different accent or palette for one card or screen: el.theme()
To give one element colors of its own — a promo card in another accent, a dark screen in a light
app, one tab bar — call el.theme(values): everything under it, your styles and the kit's, reads
these values before the app's. Same keys, same value forms, chainable, and live: calling it again
restyles the subtree, null takes a key out of the scope.
theme({ accent: "#15A34A", surface: "#FFFFFF" })
const card = (title: string) => UIColumn(
UIText(title),
UIText("Buy").style({ color: "var(--accent)" }),
).style({ bgColor: "var(--surface)", borderRadius: 16, p: 16 })
const promo = card("Sale").theme({ accent: "#FF3B30", surface: "#FFF1F0" }) // this card only
Router.init(UIScreen(card("Regular"), promo).style({ p: 16, gap: 12 }))- A scope reaches the element's descendants in the tree — a pager's pages and a list's items
included (
tabs.theme(...)colors every tab's screens). - A screen's widgets (a dialog, a sheet, a HUD) are in the screen's scope — set on the screen, or on a pager or tab shell that shows it.
- A screen pushed over it is a root of its own: no scope reaches it. Give it its own
.theme(...). - The nearest scope wins; a key it doesn't hold falls through to the next one up, then to the app's table.
text/fontFamilyin a scope restart the default text style for that subtree.- The comfort knobs and
replaceTransitionare app-wide — set them withtheme()(el.theme()skips them with a warning).
Page margins next to the screen edges: comfort knobs
The comfort-* style tokens keep content clear of the notch, the status bar and the edges by a
margin the app tunes: "comfort-top" / "comfort-bottom" / "comfort-left" / "comfort-right"
in theme() set it (logical px, plain lengths only; defaults 12 top, 8 bottom, 16 left and right —
how the tokens combine with the safe areas: styling.md).
theme({ "comfort-left": 24, "comfort-right": 24 }) // this app's page gutter: 24
Router.init(UIScreen(
UIText("Settings").style({ fontSize: 22 }),
UIText("Every screen padded with comfort-x moves with the knobs"),
).style({ pt: "comfort-top", px: "comfort-x", gap: 12 }))the bare style token (pt: "comfort-top") applies the safe-area formula;
var(--comfort-top) reads the raw knob value (default 12) for manual composition.
Default transition of Router.replace: replaceTransition
One key is consumed by the SDK router instead of entering the variable table: the app-wide default
transition of Router.replace (out of the box "none") — any transition name or a spec of your
own ({ enter, exit }); null resets it.
theme({ replaceTransition: "fade" }) // every Router.replace without a transition of its own fades
const home = UIScreen(UIText("Home"))
const login = UIScreen(
UIButton(UIText("Sign in")).onClick(() => Router.replace(home)),
).style({ p: 16 })
Router.init(login)An explicit per-call Router.replace(screen, { transition }) still wins. It returns no accessor —
var(--replaceTransition) is not a style value. See screen-router.md.
Pitfalls
// ✗ keys of your own for the kit — it has none, it reads the roles
theme({ tabBarActive: "#15A34A" })
// ✓
theme({ accent: "#15A34A" })
// var() works in UI styles only — keep the raw palette next to the accessors for everything else
const palette = { accent: "#15A34A", surface: "#FFFFFF" }
const colors = theme(palette)
// ✗ SVG markup, engine colors and native-view params get the literal string "var(--accent)"
SvgSource(`<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="${colors.accent}"/></svg>`) // the fill is ignored
sprite.color = colors.accent // engine APIs parse CSS colors, not var(): throws a TypeError
// ✓ the raw value there — or tint the image with a UI style, which does read var()
SvgSource(`<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="${palette.accent}"/></svg>`)
UIImage(icon).style({ tintColor: colors.accent })
// ✗ theming an env name — host-owned, skipped with a warning
theme({ "safe-top": 0 })
// ✗ a formula in a comfort knob — knobs take plain lengths (this one reads as 0)
theme({ "comfort-bottom": "max(safe-bottom, 16px)" })
// ✓ write the formula inline in the style, or as your own variable
theme({ tabInset: "max(safe-bottom, 16px)" }); bar.style({ pb: "var(--tabInset)" })See also
- Styling — the property vocabulary and value forms variables plug into
- Style classes — per-element named states (a class flips styles; a scope swaps the variables they read)
- Fonts — the
font()macro,fontFamily - UIPager & UITabs — the tab bar the roles color