Theme
This page is for the app developer and the designer who owns the brand. It explains what a theme contains, how a renderer reads it, and what happens when a value or the whole theme is missing. Current theme contract version: 4.0.1.
Related pages: index · quickstart for Flutter · quickstart for React · compatibility · widgets · changelog
Tokens and values#
A screen refers to colors, sizes and text styles by token name:
{ "type": "Text", "props": { "content": { "kind": "literal", "value": "Hello" } }, "style": { "color": "primary", "typography": "titleM" } }The theme gives those names values:
{ "color": { "primary": "#4f46e5" }, "typography": { "titleM": { "fontSize": 18, "lineHeight": 1.3333, "fontWeight": 700, "role": "heading" } } }Two separate contracts follow from this:
| Contract | Says | Artifact |
|---|---|---|
| Widget schema | which shape a token name has, and which names every theme must have (the core) | screen-schema.json |
| Theme | which names exist and what each one means | theme.json |
A rebrand replaces the theme file. No document changes and no app release is needed for a new color.
Since contract 9.0.0 a style field also accepts a value of the document's own — a number, a color, a token with an opacity. The token stays the shortest form. A value of the document's own does not follow a rebrand, and it is the same in the light and the dark theme; a tint of a token ({ "token": "primary", "opacity": 0.1 }) follows both. The forms are in the widget reference.
The theme declares its own tokens#
Six dictionaries of a theme are open since theme 4.0.0: color, gradient, spacing, radius, elevation, typography.
"color": {
"primary": "#4f46e5", "onPrimary": "#ffffff", "surface": "#ffffff",
"brandCoral": "#ff7a59",
"veil": "#11182780"
},
"spacing": { "none": 0, "xs": 4, "sm": 8, "md": 12, "lg": 16, "xl": 24, "xxl": 32 }- The core is required. Every theme has the names listed in the
x-tokens.coreblock of the widget schema — the names in the table under What a theme contains. Roles, the root text style and the defaults of widget properties refer to them. - Names of your own are free. A name matches
^[a-z][a-zA-Z0-9]{0,39}$; a dictionary holds at most 64 names beyond the core. - A renderer keeps no list of names. It takes them from the theme it draws with (
customTokens: 'declared-by-theme'). A renderer with a built-in list of "known colors" silently does not drawbrandCoral.
Three rules the schema of a theme cannot express are listed in the x-rules block of the artifact, and the builder enforces them:
| Rule | Meaning |
|---|---|
sameTokenNamesAcrossThemes |
Every theme of the set declares the same names. A document refers to a name, not to a theme; a name missing from the dark theme would make the screen look right until dark mode is switched on. |
roleColorsExist |
A role names a color the same theme declares. |
documentTokensExist |
A token a document names is declared by the project theme. The builder checks this before publication. |
What a renderer does when a theme from elsewhere breaks one of them is fixed by the policy: a token the theme does not declare is an unknown token.
Publish a theme with a new token before the document that uses it. Remove a token only after no published document names it; the builder's theme editor shows which documents a removal or a rename breaks.
The file#
theme.json is an envelope. The values are under contract; the root also carries a JSON Schema for the payload (x-schema) and the policy blocks described below.
{
"x-themeContractVersion": "4.0.1",
"x-compatibility": { "policy": { } },
"x-optionalColors": { "tokens": [ ], "fallbacks": { } },
"x-rules": [ ],
"x-schema": { },
"contract": {
"themeContractVersion": "4.0.1",
"defaultThemeId": "light",
"themes": {
"light": { "id": "light", "brightness": "light" },
"dark": { "id": "dark", "brightness": "dark" }
}
}
}The published theme of a project is the payload only — the object under contract, without the envelope. The Flutter renderer accepts both forms.
A set of themes#
The contract holds a map of themes, not one theme.
| Field | Meaning |
|---|---|
themes |
Map from theme id to theme. The key and the theme's own id are always equal. The key __proto__ is refused (propertyNames.pattern), and a renderer looks a theme up with an own-property check, so that themes['__proto__'] is "not found", not a prototype. |
defaultThemeId |
The theme a renderer takes when the host asked for nothing specific. It always names an existing theme. |
The same document renders in any theme of the set. Which theme to show is the host's decision, never the document's.
Light and dark#
Every theme has brightness: light or dark. A host maps the system mode to a theme by this field and does not hard-code theme ids.
The built-in set has two themes, light and dark. In Flutter:
final theme = contract.themeForBrightness(
WidgetsBinding.instance.platformDispatcher.platformBrightness,
);If no theme has the requested brightness, the default theme is returned.
What a theme contains#
| Key | Value | Core token names (a theme may add its own) |
|---|---|---|
color |
#rrggbb or #rrggbbaa, lowercase |
primary, onPrimary, secondary, accent, surface, onSurface, muted, border, success, warning, danger, and the optional locked, inProgress, completed |
spacing |
integer, logical pixels, 0–128 | none, xs, sm, md, lg, xl |
radius |
integer, logical pixels, 0–256 | none, sm, md, lg, pill |
typography |
text style, see below | displayL, titleM, titleS, bodyM, bodyS, caption, numericL |
elevation |
list of shadow layers, possibly empty | none, low, medium |
gradient |
angle and color stops | brandBackdrop |
fontFamilies |
font names per role — a closed set | body, heading |
roles |
semantic role → color tokens — a closed set of roles | badgeTone.*, buttonVariant.* |
id, name, brightness |
identity of the theme | |
source, origins, provisional |
where the values came from |
Units are part of the contract: lengths are logical pixels (lengthUnit: 'logical-pixels').
The token none is zero, always (zeroScaleToken: 'none-is-zero'). spacing.none and radius.none are 0 (const: 0 in the theme schema), elevation.none is an empty list. Migration and node templates write "no spacing" as none, so a theme where none is 16 would change a migrated screen without a byte of the document changing. The builder does not let you edit these three values, and a renderer declares --product-space-none: 0 even for a partial theme that omits it (x-tokens.zeroScale in the widget schema names the token and the three dictionaries).
A color may carry its own opacity: #rrggbbaa, with the alpha as the last pair, as in CSS (colorAlpha: 'rrggbbaa-alpha-last'). Flutter's usual notation, 0xAARRGGBB, puts it first. When a document tints such a color ({ "token": "veil", "opacity": 0.5 }), the two opacities multiply.
Three token sets are not in the theme, because a rebrand does not change them by value: alignment (start, center, end), icons and image assets. They are resources of the platform.
Text styles#
"titleM": { "fontSize": 18, "lineHeight": 1.3333, "fontWeight": 700, "letterSpacing": -0.2, "role": "heading" }| Field | Required | Meaning | Limits |
|---|---|---|---|
fontSize |
yes | Size in logical pixels. | 8–96 |
lineHeight |
yes | A multiplier of the font size, not pixels (lineHeightUnit: 'font-size-multiple'). 1.3333 at size 18 is a 24-pixel line. |
0.8–3 |
fontWeight |
yes | Integer weight, a multiple of 100 (multipleOf: 100): Flutter has exactly nine FontWeight values. The same holds for a font edit inside a document. |
100–900 |
letterSpacing |
no | Extra space between letters, in logical pixels, not em. Negative values are legal. Absent means zero (missingLetterSpacing: 'zero'), not the tracking of the platform's default text style. |
−2 … 8 |
role |
yes | Which font family the style uses: body or heading. |
In Flutter, lineHeight goes to TextStyle.height and letterSpacing goes to TextStyle.letterSpacing unchanged.
Fonts are named, not delivered#
"fontFamilies": {
"body": { "primary": "Inter", "fallback": ["system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"] },
"heading": { "primary": "Inter", "fallback": ["system-ui", "-apple-system", "sans-serif"] }
}The theme tells you the name of the font. It does not contain the font file and there is no delivery mechanism for it.
- If your app bundles a font with that family name, the renderer uses it.
- If it does not, the platform's fallback font is drawn. Nothing reports this.
- Changing the font therefore still requires an app release.
This is a known open item of the contract, shared by every renderer.
Roles#
A Badge has a tone (success, warning, …) and a Button has a variant (primary, secondary, ghost). Neither names a color. The theme says which colors stand behind each role — any colors of the same theme, including names of your own:
"roles": {
"badgeTone": { "success": { "background": "success", "foreground": "onPrimary" } },
"buttonVariant": { "ghost": { "foreground": "primary" } }
}background and border are optional. A missing slot means "not drawn", not "transparent color": a ghost button has no fill at all.
A role the theme does not describe is drawn neutrally (missingRole: 'render-neutral'): the label in the root onSurface (not an inherited color), no background, no border. The same holds when the theme has no roles at all or a role of the wrong shape. A role that names a color the theme does not declare is neutral slot by slot (unknownRoleColor: 'neutral-slot'): a background or a border with an undeclared color is not drawn, a label color falls back to the root onSurface, and the other slots of the role stay as they are.
A role border is 1 dp (roleBorderWidth in x-style.roles) and exists only for a role with a border slot that points to a known color; otherwise there is neither a border nor room for one (withoutBorderSlot: 'no-border-no-space'). A color alone cannot say it — an absent slot is transparent in CSS too — so the builder writes one more variable per role, --product-role-<group>-<role>-border-width, with 0 or 1px. Use it in the style of the role; a transparent border would take a pixel that nobody takes on the phone.
The role is the default look, not the only one. Since contract 9.0.0 a Button or a Badge with a style of its own overrides the role slot by slot: style.background replaces the background and leaves the label color of the role. The rule is in the widget reference.
The Flutter renderer treats a theme without any roles as unusable and skips it.
Shadows and gradients#
elevationvalues are lists of at most four layers{ offsetX, offsetY, blur, spread, color, opacity }. The first layer is the nearest one (shadowOrder: 'first-is-nearest').noneis an empty list. The color of a layer is#rrggbb; its opacity is the separate field. Offsets and spread are within ±64, blur within 0–128.- A gradient has
angleand 2–8stopsof{ color, position }. Stop positions do not descend (gradientStopOrder: 'non-descending'); equal positions are in order — that is a hard color edge. A gradient whose stops go backwards is not drawn at all (unorderedGradient: 'do-not-draw').
The angle is one of eight values, and they are of two kinds (gradientAngleSpace: 'axis-as-css-diagonal-css-to-corner', diagonalGradientGeometry: 'css-to-corner'):
| Angle | Meaning | Flutter | CSS |
|---|---|---|---|
0, 90, 180, 270 |
along an axis, as a CSS angle: 0 is bottom to top, 90 is left to right |
bottomCenter → topCenter, centerLeft → centerRight, … |
0deg, 90deg, … |
45 |
towards the top right corner | computed from the box size, see below | to top right |
135 |
towards the bottom right corner | computed from the box size | to bottom right |
225 |
towards the bottom left corner | computed from the box size | to bottom left |
315 |
towards the top left corner | computed from the box size | to top left |
The diagonal values are not CSS degrees, and they are not "a line from corner to corner" either: they are exactly the geometry of CSS to <corner> (x-gradientGeometry in both artifacts). The gradient line passes through the center, perpendicular to the diagonal that joins the two other corners, so the 50% isoline passes through those two corners and the target corner gets position 1. On a square this is the same as corner to corner; on a wide card it is not, and 135deg is different again. For a box w×h and direction signs (sx, sy) in normalized coordinates, end = (0.5 + sx·h²/(w²+h²), 0.5 + sy·w²/(w²+h²)) and start is symmetric about the center. A browser computes this itself; Flutter must compute Alignment(2x−1, 2y−1) from the size of the box — the constants topLeft → bottomRight are right only on a square. The direction is physical: it does not flip in a right-to-left script.
Where the values came from#
source is a free-text note. origins maps a token to its name in a design system. provisional lists tokens whose values no designer supplied.
All values of the two built-in themes are provisional: they are a starter palette to be replaced with your brand. A renderer does not need these three fields to draw.
Optional state colors and their fallbacks#
Three colors describe the state of an element: locked, inProgress, completed. They are optional in a theme. A theme may leave them out, and that is a valid theme. They are listed with the core names because a document may always name them.
When a value is missing, do not invent one. The contract names the token to use instead, in the x-optionalColors block of the artifact:
"x-optionalColors": {
"tokens": ["locked", "inProgress", "completed"],
"fallbacks": { "locked": "muted", "inProgress": "accent", "completed": "success" }
}| Missing | Use |
|---|---|
locked |
muted |
inProgress |
accent |
completed |
success |
These are the tokens the three states borrowed before they had names of their own. An older theme in a newer renderer therefore looks exactly as it did.
The Flutter renderer mirrors the map as kThemeColorFallbacks. Its lookup order for a color is: the theme's value, then the documented fallback token.
A tint of a missing state color ({ "token": "locked", "opacity": 0.25 }) is computed from the fallback value. A state color is never an "unknown token".
What themeContractVersion means#
The version describes the structure of a theme: which fields exist and what they mean. It does not describe the values.
- Changing a color, a size or a font name in the builder does not change the version.
- Adding, renaming or removing a token of your own does not change the version either: it is content of an open dictionary.
- A new optional field is a minor version.
- A new required field, or a new required core name, is a major version.
A change to a value of the built-in themes is a patch version of the artifact, because a renderer bundles a copy of them: 4.0.1 changed two role slots of the built-in dark theme and nothing in the structure. A theme of your own keeps the version it was written for.
4.0.0 is a major version although no value of the built-in themes changed. A renderer of 3.x validates a theme against a closed list of names, so a theme with one color of your own would be rejected by it as a whole.
A renderer compares versions with the same rule as for documents: the major must match; a newer minor is read by taking the fields the renderer knows (newerMinor: 'read-known-fields-only'). The rule and the independence of the three versions are on the compatibility page.
One limit is stated in the artifact itself: x-schema describes exactly one minor version (schemaDescribesExactVersion: true). Do not validate a newer theme with an older schema; read it by keys instead.
Where the published theme lives#
A project has one published theme set, next to its documents:
orgs/{orgId}/projects/{projectId}/theme/published.json- The file is readable without a token, like a published document.
- It contains the whole set: every theme plus
defaultThemeId. - Publishing the theme is a separate action in the builder. There is no draft and no autosave for the theme.
- The theme a manager is editing in the builder and the theme apps take are different things. Apps take
defaultThemeId.
The address form used by the demo host is shown in the quickstart. Other delivery options are on the delivery page.
How the Flutter renderer loads it#
final themeLoad = await ThemeLoad.fromNetwork(
Uri.parse(themeUrl),
transport: HttpDocumentTransport(),
assetPath: 'assets/hotlayout/theme.json', // default: 'assets/contract/theme.json'
// timeout: kThemeFetchTimeout, // 3 seconds by default
);The loader never throws. It walks three levels:
| Level | Used when |
|---|---|
| 1. Published theme from the network | the request answered 200 with a readable theme |
| 2. Theme file in the app bundle | the network did not answer, timed out, or answered anything but 200 |
3. Snapshot compiled into the renderer (ThemeContract.fallback()) |
the bundled file is missing or unreadable, or the network returned something that is not a usable theme |
ThemeLoad tells you which level you got:
status |
Meaning | Theme in contract |
|---|---|---|
ok |
Read as asked. | the theme you asked for |
networkUnavailable |
No connection, timeout, 404 or 5xx. | bundled file (or the compiled snapshot if that is missing too) |
assetMissing |
The bundled file could not be read. | compiled snapshot |
malformed |
Not JSON, no themes, no usable theme, or no version. |
compiled snapshot |
incompatibleVersion |
The theme's major version differs from the renderer's. | compiled snapshot |
isFallback is true for everything except ok. detail is a log line.
The contract calls this unreadableTheme: 'fallback-and-report'. Both halves matter:
- Fallback. A theme is appearance, not content. A screen in slightly wrong colors is better than no screen.
- Report. A substituted theme is hard to see by eye. Log
statuson every start, and decide for yourself whether a brand-critical screen may be shown in fallback colors.
A theme that is read only partly is not replaced as a whole (partialTheme: 'resolve-missing-to-defaults'): one broken or missing core token is resolved by the renderer's defaults and the rest of the theme is kept. "The renderer's defaults" are those of the built-in theme of the same brightness (partialThemeDefaults: 'built-in-theme-of-same-brightness'): a dark theme without surface gets the dark surface. Only the core of the six dictionaries and the font families are filled — never custom tokens, the optional state colors, or roles. A token of your own that the theme lacks has no default to resolve to: the property that names it is not applied (missingCustomToken: 'treat-as-unknown-token').
Load the theme before the first frame. The reasons and a full main() are in the quickstart.
How the React renderer loads it#
<HotlayoutScreen> takes the parsed theme file as the theme prop; loadTheme(url) fetches it. Without the prop, or with a theme it cannot read, the built-in theme is drawn and the host is told through onReport (theme-fallback); a theme with holes is completed from the built-in theme of the same brightness (invalid-theme-values). themeId picks a theme of the set. The code is in the React quickstart.
A web page cannot fetch the published theme from our storage in a browser on your own domain — the same rule as for a document: see Reading from a browser.
Policy keys of the theme contract#
All keys of x-compatibility.policy in theme.json, with their meaning for a renderer, are listed on the compatibility page.