GuidesColors and themingOne colors.json feeds every platform's colour handling: the palette per mode, the generated ColorManager, and — on web — the Tailwind `@theme` block. `jui build` also registers colours for you: a raw hex in a layout is extracted into the palette and the layout is rewritten to reference the name. This page covers what that does, when it helps, and where it stops short of a curated palette.8 min read
1. The palette file`Resources/colors.json` sits next to `strings.json` and is distributed to every platform by `jui build`. Its shape is themed: a `modes` list, a `fallback_mode` used when a key is missing from the current mode, a `systemModeMapping` that says which mode the OS appearance selects, and one `{ key: hex }` object per mode. A flat `{ key: hex }` file from an older project is still accepted — the first build migrates it into the themed shape and says so in the log.The file travels the same route as strings.json — see /guides/localization for the other half of the Resources pipeline. Keys are mode-agnostic on the layout side: a layout says `"fontColor": "ink_muted"` and the runtime picks the value for whichever mode is current. Two modes may give the same key completely different values — that is the point.
Resources/colors.json
{ "modes": ["light", "dark"], "fallback_mode": "light", "systemModeMapping": { "light": "light", "dark": "dark" }, "light": { "ink": "#0B1220", "ink_muted": "#475467", "surface": "#FFFFFF", "accent": "#2563EB" }, "dark": { "ink": "#F6F5F1", "ink_muted": "#D1CFC7", "surface": "#16161A", "accent": "#D4A574" }}2. Colours register themselvesDuring `jui build`, every layout is scanned for colour-bearing attributes (`background`, `fontColor`, `borderColor`, `tintColor`, `tapBackground`, `caretColor`, and their siblings — /reference/attributes/style lists them with their per-platform notes). A literal hex is looked up in the palette; if it is not there it is added under a generated name, and the distributed layout is rewritten to reference the name instead of the hex. Binding expressions are skipped — `@{...}` is left exactly as written.• The generated name is derived from the colour itself: brightness picks the prefix (`white` / `pale` / `light` / `medium` / `dark` / `deep` / `black`) and the dominant channel adds a suffix (`_red`, `_green`, `_cyan`, …). `#123456` became `dark_cyan` and `#FE4A49` became `medium_red` in a measured run. Descriptive, not meaningful — that is what makes it a placeholder rather than a design token.• A hex that already exists in the palette resolves to that key instead of creating a new one. This is why a layout written with `#475467` ends up referencing `ink_muted`: the build recognised the value. It also means writing a light-mode hex silently opts that node into the token — including the token's dark value.• Extraction writes to the distributed tree, not to what you author. Your layout keeps its hex; the per-platform copy carries the name, and the new entry lands in that copy's palette in a single mode (`extract_into_mode`, defaulting to `light`). Nothing in your source directory changes, so the registration is regenerated on every build rather than remembered.• A name that is not a hex and not in any palette is recorded in `Resources/defined_colors.json` rather than dropped, so a typo or a colour you have not defined yet shows up in one place instead of failing silently at runtime.
jui build
# you write # the build distributes{ "background": "#123456" } { "background": "dark_cyan" }{ "fontColor": "#475467" } { "fontColor": "ink_muted" } [INFO] Extracting colors from 133 files (0 skipped)...[INFO] Replaced colors in 84 files[INFO] Updated colors.json with 2 new colors across 1 mode(s)[WARN] Color 'dark_cyan' is not defined for every mode in colors.json — Tailwind @theme cannot resolve it (resolving to #123456).3. What each platform getsThe palette is compiled, not shipped as data: each platform gets a generated ColorManager holding every mode's palette, plus the machinery to say which mode is current.On web there is a second output: `theme.css`, a `@generated` Tailwind v4 `@theme` block with one `--color-<name>` variable per token, plus a `:root[data-theme="<mode>"]` rule re-binding those variables for every non-base mode. The build prints the one `@import` line to add to your global stylesheet; after that, new tokens flow through without touching CSS again.
per-platform output
Platform | Generated from colors.json | How a screen reads a colour---------+---------------------------------+---------------------------------------iOS | ColorManager.swift | ColorManager.color(for: "accent") | (ColorMode enum, systemMode- | -> UIColor?, or the SwiftUI Color | Mapping keyed by UIUser- | accessor for a static name | InterfaceStyle) |Android | ColorManager.kt | ColorManager.compose.color("accent") | (object, currentMode is a | -> Compose Color; recomposition is | mutableStateOf) | automatic on a mode changeWeb | ColorManager.ts + theme.css | class `bg-accent` for a static name; | (@theme block + per-mode | ColorManager.resolveColor(x) when the | :root[data-theme="<mode>"]) | value lands in an inline style4. Switching theme at runtimeEvery platform's ColorManager exposes the same three ideas — the current mode, a way to set it, and a way to hear about changes. What differs is only how the change reaches the view layer.• Following the OS is the default. `systemModeMapping` maps the system appearance onto your mode names, so a project whose modes are not called light and dark still tracks the OS. iOS reads `UIUserInterfaceStyle` (`applySystemMode`, or feed it a `UITraitCollection`), Android reads the system dark-theme flag, and web subscribes to `matchMedia('(prefers-color-scheme: dark)')`.• `setMode` overrides it for an in-app switch, and on web `followSystemMode = false` stops the OS from taking it back. An unknown mode name is ignored with a warning rather than applied.• Propagation is per platform: Compose holds `currentMode` in a `mutableStateOf` so recomposition is automatic, iOS publishes it on an observable object, and web notifies subscribers — the generated `useColorMode` hook wraps that in `useSyncExternalStore`, and this documentation site mirrors the mode onto `<html data-color-mode>` so the CSS variables swap in one attribute write.
5. Static names vs bound coloursA colour written in the layout and a colour handed over by the ViewModel take different paths on web. A static name becomes a Tailwind class backed by the theme variable, so the theme follows with no JavaScript at all. A bound value cannot become a class — it lands in an inline style, where it is resolved through the palette at render time.The resolver takes a palette key or any CSS colour: a key becomes the current mode's value (falling back to `fallback_mode`), and a hex, `rgb()` or CSS colour name passes through untouched. It deliberately does not warn on an unresolved value — it cannot tell a mistyped key from a legitimate CSS colour name, and a warning per render would be noise. iOS and Android resolve bound colours by name too; web was the outlier until 2026-07-28.
generated web output
// static token -> Tailwind class, follows the theme with no JS<span className="text-ink_muted bg-surface">…</span> // bound colour -> inline style, resolved through the palettestyle={{ backgroundColor: ColorManager.resolveColor(data.statusBg), color: ColorManager.resolveColor(data.statusFg) }}6. A token is theme-safe only when every mode defines itThe web `@theme` block mirrors mode-complete names only — names present in every mode. A name defined in one mode cannot be a theme variable, because there would be nothing to swap it to. When the mapper meets one, it resolves the name back to its fallback hex and emits that literal (`bg-[#123456]`) so the colour still renders, and warns once per name that the value will not follow the theme.This is exactly what an auto-registered colour is: it lands in one mode, so it warns until a human gives it the other modes' values. The warning is the handover point between the build's placeholder and your palette — it is not something to silence.
7. Things worth knowing before you rely on itAutomatic registration is a safety net for hexes that slipped into a layout. It is not a palette designer, and a few of its edges are sharp.• Promote, do not accumulate. A generated name like `medium_red` says nothing about intent; give the colour a real name in `colors.json` for every mode and use that name in the layout. Until you do, the build repeats the same warning every run.• A hex that matches a token is a token. Copying `#2563EB` out of a design file gives you `accent`, which in dark mode is a completely different colour. If you wanted a fixed colour rather than the token, do not spell it with the token's light-mode value.• JsonUI hex is alpha-first (`#AARRGGBB`), which CSS cannot parse — it reads `#RRGGBBAA`. The theme emitter converts 8-digit values to `rgba()` for you; a non-hex value (a CSS colour name, say) is simply skipped when the token list is built.• Inline styles do not re-theme by themselves. A bound colour is read at render time, so a mode switch reaches it only on the next render — the same constraint Compose's `ColorManager.compose.color` has. Static names have no such gap: the CSS variable changes underneath them.
Keep reading
Adding a new languageThe same Resources pipeline for strings.json, locale by locale./guides/localization
Style attributesEvery colour-bearing attribute, with its per-platform notes./reference/attributes/style
ReactJsonUIHow token classes and theme.css fit into the web output./platforms/react