JsonUI
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.
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"
}
}
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.
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.
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).
• 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.
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.
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 change
Web | 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 style
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.
4. 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.
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 palette
style={{ backgroundColor: ColorManager.resolveColor(data.statusBg),
color: ColorManager.resolveColor(data.statusFg) }}
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.
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