Theme & tokens
Step 3 of the UI track. In the program from the previous step the colors live in four constants at the top of the file — better than hex all over the code, but a constant can't change while the app is running. Today the palette moves into theme(), we get a dark/light switch for free, drop color: "white" from every UIText, and set up shared typography styles.
Tokens instead of constants
theme(values) puts variables into one app-wide table and returns accessors — an object with the same keys whose values are strings like "var(--card)". Such a string can go into any UI style, and the host resolves it at apply time. Numbers are lengths in logical px, strings pass through as-is.
Two keys are special — the SDK itself reads them: color sets the default text color for every UIText without its own color, fontFamily the default font. One line instead of color: "white" on every element:
const colors = theme({
bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032",
})
theme({ color: "#ffffff" }) // default text color — for every UIText without its own color
const Stat = (value: string, label: string) =>
UIColumn(
UIText(value).style({ fontSize: 22, fontWeight: 800 }),
UIText(label).style({ color: colors.muted, fontSize: 13 }),
).style({ bgColor: colors.card, borderRadius: 14, p: 12, gap: 2, flex: 1, alignItems: "center" })
const screen = UIScreen(
UIText("Today").style({ fontSize: 32, fontWeight: 800 }),
UIText(date().format("dddd, DD MMMM")).style({ color: colors.muted, fontSize: 15 }),
UIRow(Stat("3", "done"), Stat("2", "left"), Stat("60%", "today")).style({ gap: 10, mt: 8 }),
UIButton(UIText("+ New habit").style({ fontSize: 16, fontWeight: 700 }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, mt: 16, $pressed: { opacity: 0.8 } }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
screen.open()The same pixels as before — but not one hex literal below the first four lines. What to know about the table:
theme()merges: keys not mentioned in a call keep their values. That's why the palette and the system keys can be set in separate calls.colors.cardis literally the string"var(--card)". It works only in UI styles: engine colors (Sprite.color, materials), SVG markup and native-view params receive the literalvar(--card)and can't parse it. Keep a raw hex palette next to the accessors for those.- A
var()can carry a fallback —"var(--accent, #FF4032)"— used while the key is unset. - Variables are for what should change at runtime. Spacing and font sizes stay plain TypeScript constants.
Dark and light
Since every var() re-resolves at apply time, calling theme() again restyles whatever is on screen right now, in place — no tree rebuild, no prop threading, no call site that can be forgotten. Dark mode is a second call with a different palette:
const dark = { bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" }
const light = { bg: "#f2f2f7", card: "#ffffff", text: "#111114", muted: "#6b6b76", accent: "#FF4032" }
const colors = theme(dark)
theme({ color: dark.text })
const isDark = signal(true)
const applyTheme = (d: boolean) => {
isDark.value = d
const p = d ? dark : light
theme({ ...p, color: p.text }) // rewrite the palette + the default text color
}
const ThemeToggle = () =>
UIButton(UIText(() => (isDark.value ? "Light" : "Dark")).style({ color: colors.muted, fontSize: 13, fontWeight: 600 }))
.style({ height: 32, px: 14, borderRadius: 16, bgColor: colors.card, $pressed: { opacity: 0.7 } })
.onClick(() => applyTheme(!isDark.value))
const Habit = (title: string, time: string, color: string) =>
UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: color }),
UIText(title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIText(time).style({ color: colors.muted, fontSize: 13 }),
).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" })
const screen = UIScreen(
UIRow(
UIText("Today").style({ fontSize: 32, fontWeight: 800, flexGrow: 1 }),
ThemeToggle(),
).style({ alignItems: "center" }),
UIText(date().format("dddd, DD MMMM")).style({ color: colors.muted, fontSize: 15 }),
UIColumn(
Habit("Morning run", "07:30", "#34c759"),
Habit("Read 20 pages", "13:00", "#ff9f0a"),
Habit("Stretching", "20:30", "#af6bff"),
).style({ gap: 10, mt: 16 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
screen.open()Tap the chip — the background, the cards, the captions and even the unstyled text repaint in one call. The isDark signal is only there for the chip to swap its label; the repaint itself is the table's job, not reactivity's.
- Order doesn't matter:
theme()before the screen is built or after — same result, the write lands immediately. - A server-driven brand accent or a per-user color works the same way: derive a couple of variables from one hex, write them into the theme, everything repaints — mounted screens included.
- The habit colors (
#34c759and friends) stayed literals on purpose: they're data, not styling — they shouldn't change with the theme.
Want to remember the user's choice across launches — store isDark in localStorage and call applyTheme at startup. How: in the Data & networking step (being written); until then the localStorage reference is enough.
Font and typography
An app-wide font is the same system key. font() is a compile-time macro: it takes a family from the registry (~26 OFL fonts with Cyrillic) or your own .ttf from the project, registers the needed faces and returns the family name. Nothing to await or load by hand:
theme({ color: "#ffffff", fontFamily: font("manrope") }) // all app text — Manrope
UIText("Heading").style({ fontFamily: font("unbounded") }) // per element — a display face
theme({ fontFamily: font("./fonts/Brand.ttf") }) // your own file: name and weight read from itThe argument is a string literal; an unknown id is a compile error listing the valid ones. Three system families ("serif", "sans-serif", "monospaced") work without font(). Details and the registry list: the Fonts reference.
Sizes and weights, unlike colors, don't need to change at runtime — they're plain style objects. Type them with the global Style<T> helper and spread them into .style({ ...type.h1 }):
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const type = {
h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
h2: { fontSize: 22, fontWeight: 800 } as Style<UIText>,
body: { fontSize: 16, fontWeight: 600 } as Style<UIText>,
small: { fontSize: 13, color: colors.muted } as Style<UIText>,
}
const card: Style<UIColumn> = { bgColor: colors.card, borderRadius: 16, p: 14 }
const screen = UIScreen(
UIText("Today").style(type.h1),
UIText(date().format("dddd, DD MMMM")).style({ ...type.small, fontSize: 15 }),
UIColumn(
UIText("Read 20 pages").style(type.body),
UIText("13:00 · 12-day streak").style(type.small),
).style({ ...card, gap: 4, mt: 16 }),
UIColumn(
UIText("60%").style(type.h2),
UIText("done today").style(type.small),
).style({ ...card, gap: 2, alignItems: "center", mt: 10 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
screen.open()Style<UIText> is a type only — nothing to import or instantiate; it gives you property completion in the editor and catches typos. The spread { ...type.small, fontSize: 15 } is ordinary TypeScript: the later key wins. Notice that type.small carries color: colors.muted — a style can reference a theme variable and then repaints with it.
The tokens module
In a real project all of this lives in one file every screen imports — the standard shape of a LeCodes app:
// theme.ts
const dark = { bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" }
const light = { bg: "#f2f2f7", card: "#ffffff", text: "#111114", muted: "#6b6b76", accent: "#FF4032" }
export const palette = dark // raw hex — for anything that isn't a UI style
export const colors: { [K in keyof typeof dark]: string } = theme(dark)
theme({ color: dark.text, fontFamily: font("manrope") })
export const setDark = (d: boolean) => { const p = d ? dark : light; theme({ ...p, color: p.text }) }
export 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>,
}// screens/today.ts
import { colors, type } from "../theme"
export const todayScreen = UIScreen(
UIText("Today").style(type.h1),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })The { [K in keyof typeof dark]: string } annotation on the export isn't decoration: theme() infers literal types ("var(--accent)"), and a function with a parameter (color = colors.accent) would narrow it to that one token.
The whole screen
The tracker from the previous step on tokens: palette and theme switch at the top, typography as shared objects, not a single color: "white". The list, the stats and the button work as before:
const dark = { bg: "#0e0e12", card: "#1a1a20", text: "#ffffff", muted: "#8a8a93", accent: "#FF4032" }
const light = { bg: "#f2f2f7", card: "#ffffff", text: "#111114", muted: "#6b6b76", accent: "#FF4032" }
const colors = theme(dark)
theme({ color: dark.text })
const isDark = signal(true)
const applyTheme = (d: boolean) => {
isDark.value = d
const p = d ? dark : light
theme({ ...p, color: p.text })
}
const type = {
h1: { fontSize: 32, fontWeight: 800 } as Style<UIText>,
h2: { fontSize: 22, 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 }
const habits = signal<Habit[]>([
{ id: 1, title: "Morning run", time: "07:30", color: "#34c759", done: true },
{ id: 2, title: "Read 20 pages", time: "13:00", color: "#ff9f0a", done: true },
{ id: 3, title: "Practice piano", time: "18:00", color: "#5e9eff", done: false },
{ id: 4, title: "Stretching", time: "20:30", color: "#af6bff", done: false },
])
const doneCount = 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 IDEAS = ["Glass of water", "10 minutes of meditation", "A walk", "No phone before bed"]
const addHabit = () => {
const n = habits.value.length
habits.value = [...habits.value, { id: n + 1, title: IDEAS[n % IDEAS.length], time: "21:00", color: dark.accent, done: false }]
}
const Chip = (label: () => string, onTap: () => void) =>
UIButton(UIText(label).style({ ...type.small, fontWeight: 600 }))
.style({ height: 32, px: 14, borderRadius: 16, bgColor: colors.card, $pressed: { opacity: 0.7 } })
.onClick(onTap)
const Stat = (value: () => string, label: string) =>
UIColumn(UIText(value).style(type.h2), UIText(label).style(type.small))
.style({ bgColor: colors.card, borderRadius: 14, p: 12, gap: 2, flex: 1, alignItems: "center" })
const HabitRow = (h: Habit) =>
UIButton(
UIColumn(UIText(h.done ? "✓" : "").style({ fontSize: 15, fontWeight: 700, color: "#ffffff" })).style({
width: 28, height: 28, borderRadius: 14, alignItems: "center", justifyContent: "center",
bgColor: h.done ? h.color : "transparent", border: h.done ? "0" : `2px solid ${h.color}`,
}),
UIColumn(UIText(h.title).style(type.body), UIText(h.time).style(type.small)).style({ gap: 2, flexGrow: 1 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, opacity: h.done ? 0.75 : 1, $pressed: { opacity: 0.6 } })
.onClick(() => toggle(h))
const screen = UIScreen(
UIRow(
UIText("Today").style({ ...type.h1, flexGrow: 1 }),
Chip(() => (isDark.value ? "Light" : "Dark"), () => applyTheme(!isDark.value)),
).style({ alignItems: "center" }),
UIText(date().format("dddd, DD MMMM")).style({ ...type.small, fontSize: 15 }),
UIRow(
Stat(() => `${doneCount.value}`, "done"),
Stat(() => `${habits.value.length - doneCount.value}`, "left"),
Stat(() => `${Math.round((doneCount.value / habits.value.length) * 100)}%`, "today"),
).style({ gap: 10, mt: 8 }),
UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
UISpacer(),
UIButton(UIText("+ New habit").style({ ...type.body, fontWeight: 700, color: "#ffffff" }))
.style({ bgColor: colors.accent, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
.onClick(addHabit),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)", gap: 4 })
screen.open()Note the two places where a color is set explicitly, and rightly so: the check mark inside the colored circle and the label on the accent button — they sit on a colored surface and must stay white in both themes. Everything else follows the table.
What you learned
theme(values)is the app's one variable table; it returns accessors like"var(--key)"that plug into any UI style.- The system keys
colorandfontFamilyset the default text — no morecolor: "white"on every element. - Calling
theme()again restyles open screens in place: dark mode, a brand, a per-user accent — one call. font("id")is a compile macro: using a font declares it. Nothing to wait for.- Sizes and weights are plain
Style<T>objects spread into.style(); in a project all of this lives in atheme.tsmodule.
Reference: Theme · Styling · Fonts · Element model.
Next: lists & data — scrolling that doesn't stutter: UIScrollable for the screen body and UIVirtualizedList for a long history.