Theming
A project's colors, corner radius and typeface come from a theme — a preset baked into the
compiler, an optional theme.toml, and an optional inline table.
Tokens resolve in color classes, so a project gets a semantic vocabulary
— bg-card, text-muted-foreground, border-border —
instead of hex literals scattered through its markup.
The three layers
Each layer overrides the one before it:
| Layer | Where | Use it for |
|---|---|---|
| Preset | preset = "shadcn" in whitehall.toml | The baseline look |
theme.toml | Next to whitehall.toml, found automatically | The project's theme — the shareable artifact |
[theme.colors] | Inline in whitehall.toml | A one-off tweak not worth a file |
theme.toml is discovered, not registered — the way SvelteKit finds svelte.config.js. If the file is not there, the preset stands on its own.
Presets
# whitehall.toml
[theme]
preset = "shadcn"
base_color = "olive" # optional; "neutral" if you say nothing | Preset | Contents |
|---|---|
shadcn | shadcn's tokens, light and dark, over the full component library. Geist, 10dp radius. The default — say nothing about theming and this is what you get. |
material3 | Material's own components — <Button> is androidx.compose.material3.Button, with its ripple and elevation. Roboto,
12dp radius, and color classes routed through MaterialTheme.colorScheme so dynamic color works. |
none | The same component library, unstyled. Every token defined in black, white and two greys, so components render and are legible with no design in them. Paint it yourself. |
p-16, bg-red-500, blur-xl, and the semantic colors
too: bg-background, text-foreground, text-muted-foreground, bg-card, border-border, text-destructive. Write text-foreground and you get the app's text
color, whichever preset it is on.material3 gives you Material's components, not shadcn's in Material
colors. The six with a Material counterpart — <Button>, <Card>, <TextField>, <Switch>, <Checkbox>, <Icon> —
compile to the real thing. The rest of the library is shadcn's and is not available: write
your own src/components/Alert.wh if you want one.import $app.components.Alert beside <Alert> and yours is the one that compiles. Writing the import is the
statement of intent, the same way it is in Svelte. It is also how you fill a gap under material3, where the shadcn half of the library is not available.Which one Material moves
base_color picks one of shadcn's neutral ramps and only shadcn reads it. Material generates its schemes from a seed hue instead, which is a different idea,
so the setting is ignored rather than rejected there — switch presets to compare without
having to delete the line. To move Material's color, override the role:
[theme]
preset = "material3"
[theme.colors]
primary = "#00639B" The 19 tokens the shadcn preset defines:
background foreground
card card-foreground
popover popover-foreground
primary primary-foreground
secondary secondary-foreground
muted muted-foreground
accent accent-foreground
destructive destructive-foreground
border input ring Base colors
The preset builds its palette on a base color — the neutral ramp every token
except destructive is derived from. This is the same knob as tailwind.baseColor in shadcn's components.json: it swaps the neutral
underneath the whole palette without changing any other decision. Say nothing and the app is
gray; say olive and it is green.
base_color | Cast |
|---|---|
neutral | Pure gray. The default, and shadcn's. |
stone | Warm gray, faintly brown |
zinc | Cool gray, faintly blue |
mauve | Gray with a pink cast |
mist | Gray with a blue-green cast |
olive | Gray with a green cast |
taupe | Gray with an orange-brown cast |
These are shadcn's own seven, taken verbatim from the registry. A base color is a starting
point, not a commitment — everything in theme.toml and [theme.colors] still overrides it token by token.
Switching base colors at runtime
base_color picks one ramp at build time, which is what an app with a look
wants. An app that lets its user pick binds it on <App> instead, the same way theme= is bound:
// src/main.wh
import $app.stores.ThemeStore
<App theme={ThemeStore.mode} baseColor={ThemeStore.base}>
<slot />
</App> // src/stores/stores.wh
@store
object ThemeStore {
var mode: String = "dark"
var base: String = "neutral"
} Writing ThemeStore.base = "olive" repaints the whole app, with no rebuild. Every
ramp is emitted into WhitehallTheme.kt and chosen inside the composition, exactly
as the two schemes already are.
baseColor="olive" as a literal is
the same decision base_color already makes, and one setting with two spellings is
worse than one. The cost of binding it is size — seven ramps is 266 extra color literals, so
they are emitted only when baseColor= is bound. An app that never
mentions it carries one palette.theme.toml
Every field is optional; anything absent keeps its preset value.
# Which of shadcn's seven neutral ramps the palette is built on.
base_color = "neutral"
# Base corner radius, which drives the whole rounded-* scale.
# Accepts "0.625rem", "10px", "10dp", or a bare number in dp.
radius = "0.625rem"
# Tokens here apply to both schemes.
[colors]
ring = "neutral-400"
# ...and are overridden per scheme. Same cascade as shadcn's `:root` and `.dark`.
[colors.light]
background = "oklch(1 0 0)"
foreground = "#0a0a0a"
primary = "#171717"
[colors.dark]
background = "#0a0a0a"
foreground = "#fafafa"
primary = "#e5e5e5" An unknown scheme ([colors.midnight]) is an error, not a silent drop. So is an
unknown base_color.
The radius scale
radius is one number and every corner in the app is derived from it, so moving it
reshapes the whole app at once — which is the point of having the setting. shadcn's multipliers:
| Class | Multiplier | At the default 0.625rem |
|---|---|---|
rounded-sm | radius × 0.6 | 6dp |
rounded-md | radius × 0.8 | 8dp |
rounded-lg | radius | 10dp |
rounded-xl | radius × 1.4 | 14dp |
rounded-2xl | radius × 1.8 | 18dp |
rounded-3xl | radius × 2.2 | 22dp |
rounded-4xl | radius × 2.6 | 26dp |
rounded-none and the bare rounded are Tailwind's own and stay fixed, as
does everything under preset = "none".
Font
font = "geist" # geist | system | "static/Inter.ttf" | Value | Effect |
|---|---|
geist | shadcn's own typeface, bundled with the compiler. The shadcn preset's default. |
system | The platform font (Roboto on Android). Adds nothing to the APK. The none preset's default. |
| a path | A font file in the project, resolved from the project root. |
Geist ships inside the compiler (169 KB variable TTF, SIL OFL 1.1) and is written to res/font/ at build time. It is bundled rather than fetched from Google Fonts on
purpose: a downloadable font needs the network on first run and flashes a fallback before it
arrives. Projects that would rather trade a flash for 169 KB can set font = "system".
All nine weights are declared against the font's wght axis, so font-bold gets Geist's real bold rather than a synthesized smear of the Regular
master. This needs API 26, which is min_sdk.
Color values
| Form | Example |
|---|---|
| Hex | #0a0a0a, #fff, #12345678, 0a0a0a |
| oklch | oklch(0.145 0 0), oklch(62.8% 0.258 29.23), oklch(1 0 0 / 10%) |
| Tailwind palette name | neutral-900, blue-600 |
oklch is what shadcn and Tailwind v4 publish, so a theme copied from the shadcn registry pastes
in unedited. Everything normalizes to #rrggbb or #rrggbbaa before
codegen.
Inline overrides
For a single token, skip the file:
# whitehall.toml
[theme.colors]
primary = "#34d399"
muted-foreground = "neutral-400" Inline values apply to both schemes and beat theme.toml.
Using tokens
Token names are used verbatim after bg-, text- and border-:
<Column class="bg-card border border-border rounded-lg p-16">
<Text class="text-card-foreground text-lg font-bold">Total revenue</Text>
<Text class="text-muted-foreground text-sm">+20.1% from last month</Text>
</Column> Names may contain hyphens, which is what lets them mirror shadcn's vocabulary. Tokens keep
working with an opacity suffix — bg-primary/50 is the lookup plus .copy(alpha = 0.5f).
Dark mode
Which scheme the app uses is declared on <App> in src/main.wh:
<App theme="dark">
<slot />
</App> theme | Material ColorScheme | Theme tokens |
|---|---|---|
"dark" | darkColorScheme() | [colors.dark] |
"light" | lightColorScheme() | [colors.light] |
"auto" (default) | follows the OS | follows the OS |
{Store.property} | follows the store | follows the store |
"system" is accepted as a synonym for "auto", since that is what mode-watcher and next-themes call it. darkMode= is the
previous spelling of this prop and still parses; theme= wins if both appear.
All four work at runtime. Theme tokens resolve inside the composition, so "auto" follows the device and a store binding follows the store, repainting live in both cases —
no rebuild. The status bar icons follow the app's scheme too, not the device's.
The runtime token layer
Every build writes a WhitehallTheme.kt into the project package, holding the
resolved palette as Compose Unstyled theme tokens:
object WhitehallTokens {
val colors = ThemeProperty<Color>("colors")
val background = ThemeToken<Color>("background")
val mutedForeground = ThemeToken<Color>("muted-foreground")
// ...
} Both schemes are emitted, and which one is in force is decided inside the composition.
Names are converted to Kotlin: muted-foreground becomes mutedForeground. Two tokens that would produce the same Kotlin name are a build
error rather than a silent shadow.
composeunstyled-theming is
published against it. A project pinning something older is refused at load time.Current limits
- The launch window follows the theme, but has no launch screen. The window
Android draws before the app's first frame takes its color from
background, per scheme. Drawing your own launch screen over it is designed but not built. - Widgets and notifications have no theme. Glance runs in a different
composition and cannot read the app's
CompositionLocals, sosrc/widgets/keeps the Tailwind palette and hex. This is a property of Glance, not a gap to be closed. - Wear bakes its tokens. The Wear module has neither
WhitehallTheme.ktnor the theming dependency, and draws withandroidx.wear.compose, so a token insrc/wear/is the literal hex for the scheme the build picked. Pintheme=for a Wear app that uses tokens. - A token named after a Material role shadows it. With a populated palette,
bg-primaryresolves to the project'sprimaryrather thanMaterialTheme.colorScheme.primary. That is the intent, but it means turning on a preset changes existing color classes. - The single-file path has no theme.
whitehall compile foo.whdoes not readwhitehall.toml, so color classes naming theme tokens are unknown-class errors there.
See Also
- Components — the shadcn component library the preset turns on
- Tailwind Classes — the full color-class reference