Theming
A project's colors and corner radius come from a theme: a preset baked into the compiler, and an optional stylesheet of CSS custom properties.
Tokens resolve in color classes — bg-card, text-muted-foreground, border-border — so a project gets a semantic vocabulary instead of hex literals
scattered through its markup.
The two layers
The second is cascaded over the first:
| Layer | Where | Use it for |
|---|---|---|
| Preset | preset = "shadcn" in whitehall.toml | The baseline look |
| The theme stylesheet | theme.css beside whitehall.toml, or [theme] file | The project's theme — the shareable artifact |
theme.css is discovered, not registered — the way Vite finds vite.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 | The 19 core shadcn tokens, light and dark, plus the styled components |
none | Empty palette. Color classes fall through to the Tailwind palette and Material roles, and components stay Material3, exactly as they did before themes existed. Current default. |
The default is none while the styled component layer is being built out. The
preset is what switches a project's components from Material3 to shadcn — so far <Button>, with the rest tracked in Components. Turning it on today repaints every color class and reshapes the components that have been ported, while the ones that have not are
still Material. The default becomes shadcn when that mix stops being a mix.
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, with the values taken verbatim from ui.shadcn.com/r/colors/<name>.json. The registry also still serves gray and slate, which predate Tailwind v4 and are not carried here —
a project that wants either can write its ramp into the theme stylesheet.
A base color is a starting point, not a commitment: the theme stylesheet still overrides it
token by token, and a [data-theme="olive"] block applies to olive alone.
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 $lib.stores.ThemeStore
<App theme={ThemeStore.mode} baseColor={ThemeStore.base}>
<slot />
</App> // src/stores/stores.wh
@store
object ThemeStore {
var mode: String = $state("dark")
var base: String = $state("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 in whitehall.toml already makes, and one setting
with two spellings is worse than one. A name the store holds that is not a real base color
paints the project's own rather than failing — it arrives at runtime, where there is nothing
better to do. The cost 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, exactly as before.The theme stylesheet
Tokens are CSS custom properties, in shadcn's own format — so a theme from the shadcn
registry, or a web project's app.css, is used as it is:
/* theme.css, beside whitehall.toml */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--ring: #a3a3a3;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
} theme.css beside whitehall.toml is found without being named. To use
another file — a project that points at its web half's stylesheet, so the pair cannot drift —
name it:
# whitehall.toml
[theme]
file = "web/src/app.css" It is read the way a browser reads it, for <html class="dark" data-theme="…">:
| Selector | Applies to |
|---|---|
:root | both schemes |
.dark | the dark scheme |
[data-theme="stone"] | the stone base color, both schemes |
.dark[data-theme="stone"] (either order) | the stone base color, dark |
Later rules win over earlier ones, and a compound selector wins over a simple one, exactly as
in CSS — so a token set in :root reaches the dark scheme unless .dark sets it too. Anything that is not one of those selectors (@import, @theme, @layer, @font-face, rules for other elements)
and any property that is not a color (--font-sans, calc(…), var(…)) is not a token and is skipped. A value that is plainly a color and does
not parse is a build error naming the file and line. A token the stylesheet does not set keeps
its preset value.
To find where a token came from: whitehall why src/routes/+screen.wh#bg-primary names the stylesheet line that set primary in each scheme, or says the preset did. whitehall why theme.css:12 names the tokens line 12 sets.
The radius scale
--radius is one length 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 it. rem is 16px,
and a px is a dp. shadcn's own multipliers:
| Class | 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
# whitehall.toml
[theme]
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. A project that would rather trade a flash for 169 KB can set font = "system". Weights are declared against the font's wght axis
(400/500/600/700), 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 |
| oklch | oklch(0.145 0 0), oklch(62.8% 0.258 29.23), oklch(1 0 0 / 10%) |
oklch is what shadcn and Tailwind v4 publish, so a theme copied from the shadcn registry
pastes in unedited. Everything normalizes to #rrggbb, or #rrggbb/NN when translucent, before codegen.
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 starts in is [theme] mode in whitehall.toml — "light", "dark", or "auto" (the default, following the
device):
[theme]
mode = "dark" <App theme=…> in src/main.wh overrides it, and is the one way to
bind it to a store:
<App theme={Settings.mode}>
<slot />
</App> theme | Material ColorScheme | Theme tokens |
|---|---|---|
"dark" | darkColorScheme() | :root, then .dark |
"light" | lightColorScheme() | :root |
"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
SvelteKit's mode-watcher and next-themes call it.
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. Material's own ColorScheme follows the same decision.
Material components
A palette that defines primary also fills Material 3's ColorScheme.
Inside WhitehallTheme, a MaterialTheme takes each role from the token
that plays the same part, so a Scaffold, a ModalBottomSheet, a Snackbar or a spinner wears the project's colors rather than Material's baseline
lavender:
| Material role | Token |
|---|---|
primary / onPrimary | primary / primary-foreground |
secondary, secondaryContainer / on… | secondary / secondary-foreground |
tertiary, tertiaryContainer, primaryContainer / on… | accent / accent-foreground |
background, surface, surfaceBright, surfaceContainerLowest | background |
onBackground, onSurface | foreground |
surfaceVariant, surfaceDim, surfaceContainerHighest / onSurfaceVariant | muted / muted-foreground |
surfaceContainerLow, surfaceContainer | card |
surfaceContainerHigh | popover |
inverseSurface / inverseOnSurface / inversePrimary | foreground / background / primary-foreground |
error / onError | destructive / destructive-foreground |
outline / outlineVariant | input / border |
A role whose token the palette lacks keeps Material's value. surfaceTint is
transparent, because a flat palette has nothing to tint raised surfaces toward.
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 — so a theme= bound to a store, or left on "auto", can
change without a rebuild. 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. - Most components are still Material3.
<Button>is shadcn's; the rest are being ported one at a time. - 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