Theming
Override tokens, define material recipes and build a theme of your own, with Paper as a working example.
On this page
A theme gives values to the tokens and defines the material recipes that components read. You can change one shared value, limit it to part of a page, or replace the material across the interface while keeping the component markup and behavior.
For the material choices behind Moonstone and Obsidian, see Design. Default theme documents their token roles and values. To decide whether a change belongs in a theme or in a component, start with Customization.
#Retune tokens
Override a token after the import. Every component that reads it follows.
@import '@master/css';@import '@master/ui';@theme { --color-brand: oklch(58% .19 28); --color-brand-ink: oklch(99% .01 28); --radius-control: .5rem;}This changes brand surfaces and the default control radius. Small and large controls use their own radius tokens. Check --color-brand-ink against the new face; brand-colored text uses the separate --color-brand-text token and does not automatically take the new hue.
#Limit an override to one region
A token is a custom property, so it also cascades. Set one on an element to retune its descendants without a second build. Here the two checkout buttons inherit a smaller radius, while the button outside that region keeps the default:
Token scope and inheritance
A scope changes what its descendants inherit. Checkout replaces the default 10px --radius-control with 4px for Review and Pay; Continue browsing stays outside and keeps 10px. Each button’s own --btn-radius reads that inherited token. An ancestor’s --btn-radius cannot replace the button’s declaration. Values shown are before the theme’s corner scale is applied.
A token scoped to checkout
<div class="flex flex-col gap-lg w-full max-w(440) mx-auto"> <div class="sheet flex flex-col gap-sm p-md" style="--radius-control: .25rem"> <span class="text-xs fg-text-muted">Inside checkout · 4px radius token</span> <div class="flex flex-wrap gap-sm"> <button type="button" class="btn">Review</button> <button type="button" class="btn">Pay</button> </div> </div> <div class="flex flex-wrap items-center gap-sm"> <button type="button" class="btn">Continue browsing</button> <span class="text-xs fg-text-muted">Outside · default 10px token</span> </div></div>The button reads --radius-control through its own --btn-radius property. Setting --btn-radius only on the checkout container would not have this effect, because each button declares that property on itself. Use the theme token for the region, or set a component property directly on the buttons. The inline declaration can also live in a project class applied to that container.
#Keep circular corners
The default theme uses continuous corners where supported. To keep circular corners everywhere, set both corner properties:
:root { --corner-shape: round; --corner-scale: 1;}Tokens declared in @theme are also available as utilities: bg-surface-tray and shadow-lifted give custom elements the same materials as components. Importing Master UI sets the preset's shared semantic tokens to the default theme; overrides retune both components and utilities that read them.
#What a theme decides
Use this checklist when building a theme. Default theme explains the token roles and their default values.
| Decision | Tokens |
|---|---|
| The tone of each surface | --color-surface-base, -tray, -raised, -raised-low, -inset, -inset-hover, -floating, -chip, -inverse, -scrim |
| How firm each line is | --color-line-hair, -contour, -divider, -strong, -mark |
| The inks | --color-text-strong, -body, -muted, -disabled, -inverse, --color-focus, --color-focus-halo |
| Where the light falls | --color-highlight, --color-lip, --color-shade-1 to -5 |
| Depth | --shadow-pressed, -flat, -raised, -raised-hover, -lifted, -floating, -overlay |
| A face of ink | --color-surface-inverse-high, -low, --color-ink-high, -low, their -hover forms, --color-ink-pressed, --color-ink-highlight, --color-ink-edge, --shadow-ink, -ink-hover, -ink-pressed, -ink-mark, -ink-well, -ink-top, --color-surface-tip, --shadow-tip |
| A thumb | --color-thumb-high, -low, --color-thumb-on-high, -on-low, --shadow-thumb-on-ink |
| Hue | --color-brand, -brand-ink, -brand-text, --color-info, -success, -warning, -danger, --color-tone-highlight, -tone-highlight-soft |
| Pictures and supplied colors | --color-line-image, -image-strong, --color-line-on-color, --color-highlight-on-color, --color-on-image, -on-image-shade, --color-mark, -mark-underline, --color-sheen |
| Shape | --radius-chip, -control-sm, -control, -control-lg, -surface, --corner-shape, --corner-scale |
| Travel and time | --lift, --duration-press, -quick, -base, --easing-glide |
| Type | --font-weight-control, and the preset's --font-sans and --font-mono |
A package test rejects color literals in component stylesheets, keeping their colors under the theme's control. Use the installed @master/ui/theme.css as the source when checking the full set of tokens and recipes.
#Paper, a second theme
Paper is published in @master/ui/themes/paper.css. It replaces the default material with flat faces, ruled edges, tighter circular corners, warm neutrals and a green brand. Its --lift: 0px removes the hover and press travel driven by that token; component animations such as a switch thumb moving still work. It changes no component stylesheet.
@import '@master/css';@import '@master/ui';@import '@master/ui/themes/paper.css';The same markup, in each theme:
Moonstone
<div class="tray max-w(440) mx-auto"> <div class="sheet flex flex-col gap-lg p-lg"> <div class="flex flex-wrap items-center gap-sm"> <button type="button" class="btn btn-primary">Deploy</button> <button type="button" class="btn">Preview</button> <button type="button" class="btn btn-quiet">Cancel</button> <button type="button" class="btn btn-brand">Upgrade</button> </div> <div class="field"> <label for="theme-domain">Domain</label> <input class="input" id="theme-domain" value="atlas.dev"> </div> <div class="flex flex-wrap items-center gap-lg"> <label class="check"> <input type="checkbox" class="checkbox" checked>Preview builds</label> <label class="check"> <input type="radio" class="radio" name="theme-plan" checked>Team</label> <label class="check"> <input type="checkbox" role="switch" class="switch" checked>Alerts</label> </div> <div class="segmented" role="radiogroup" aria-label="Environment"> <label class="segmented-item"> <input type="radio" class="segmented-input" name="theme-environment" checked>Production</label> <label class="segmented-item"> <input type="radio" class="segmented-input" name="theme-environment">Preview</label> <label class="segmented-item"> <input type="radio" class="segmented-input" name="theme-environment">Local</label> </div> <input type="range" class="slider" value="60" aria-label="Traffic to the new release"> <div class="flex flex-wrap items-center gap-sm"> <span class="badge">Draft</span> <span class="badge badge-success badge-dot">Ready</span> <span class="badge badge-ink">New</span> </div> <div class="sheet sheet-inverse p-md text-sm">Ship the release when the checks pass.</div> </div></div>Paper
<div class="tray max-w(440) mx-auto"> <div class="sheet flex flex-col gap-lg p-lg"> <div class="flex flex-wrap items-center gap-sm"> <button type="button" class="btn btn-primary">Deploy</button> <button type="button" class="btn">Preview</button> <button type="button" class="btn btn-quiet">Cancel</button> <button type="button" class="btn btn-brand">Upgrade</button> </div> <div class="field"> <label for="theme-domain">Domain</label> <input class="input" id="theme-domain" value="atlas.dev"> </div> <div class="flex flex-wrap items-center gap-lg"> <label class="check"> <input type="checkbox" class="checkbox" checked>Preview builds</label> <label class="check"> <input type="radio" class="radio" name="theme-plan" checked>Team</label> <label class="check"> <input type="checkbox" role="switch" class="switch" checked>Alerts</label> </div> <div class="segmented" role="radiogroup" aria-label="Environment"> <label class="segmented-item"> <input type="radio" class="segmented-input" name="theme-environment" checked>Production</label> <label class="segmented-item"> <input type="radio" class="segmented-input" name="theme-environment">Preview</label> <label class="segmented-item"> <input type="radio" class="segmented-input" name="theme-environment">Local</label> </div> <input type="range" class="slider" value="60" aria-label="Traffic to the new release"> <div class="flex flex-wrap items-center gap-sm"> <span class="badge">Draft</span> <span class="badge badge-success badge-dot">Ready</span> <span class="badge badge-ink">New</span> </div> <div class="sheet sheet-inverse p-md text-sm">Ship the release when the checks pass.</div> </div></div>Read its source as a template. It is a list of tokens, a corner setting, and one recipe.
#Start a theme file
Copy Paper from the installed package when you want a flat starting point, or copy theme.css to retain the default material as your starting point:
mkdir -p themescp node_modules/@master/ui/themes/paper.css themes/app.cssImport your file after the library, then edit its tokens and recipes:
@import '@master/css';@import '@master/ui';@import './themes/app.css';Rebuild with your existing Master CSS integration. The component markup stays the same; compare a button, a field, an ink surface and an overlay in both color schemes before applying the theme throughout the app.
#Recipes
A few materials are more than one value: a face with its fall of light, a control with a face for each state. The theme writes these as mixins, and a component applies them by name. A theme file may define a mixin again; the last definition is the one every component gets.
| Mixin | What it draws | Paper |
|---|---|---|
--surface-sheet | A raised face | Flat, through --color-surface-raised-low |
--surface-ink | A sheet of ink: an inverse sheet, a sent message | Flat, through the -inverse-high and -low tokens |
--control-ink, -hover, -pressed | The primary button's face and shadow in each state | Flat, through the ink tokens |
--control-tone, -hover, -pressed | A hued control, mixed from the control's own --tone | Redefined: one flat hue |
--surface-thumb | The thumb of a switch and a slider | Flat, through the thumb tokens |
--focus-ring | Keyboard focus | Unchanged |
--on-ink | What an ink surface tells the inline chips it holds | Unchanged |
--anchored, --pop | Where a popover stands, and how it enters and leaves | Unchanged |
Reach for a token first. Paper redefines only the toned control, because that recipe mixes its face from a hue the control sets on itself, which a token at the root cannot read.
Tokens are custom properties and work at run time, on any element. Mixins are resolved when Master CSS compiles the stylesheet, so a redefined mixin applies to the whole build.
#Without Master CSS
The package also ships each theme compiled with the components into plain CSS. A CSS bundler can import Paper from the installed package; use this in place of the default standalone stylesheet:
@import '@master/ui/dist/themes/paper.css';:root { --color-brand: oklch(58% .19 28);}For a page without a bundler, serve that compiled file from your static assets; see Installation for the reset and utility limitations. dist/standalone.css contains moonstone and obsidian. A compiled file accepts custom-property overrides, but redefining a mixin requires compiling a theme with Master CSS.
#Rules for a theme
- A theme never selects a component. It sets tokens and defines mixins. A rule such as
.btn { … }in a theme is a component override; see customization for where that belongs. - Keep the pairs. A surface and the ink on it, a line and the surface under it, are chosen together. Check body text on every surface for contrast, in both color schemes.
- Keep the edges findable. If a theme removes light and shade, its lines carry more of the boundary. Paper uses firmer line tokens, tuned separately for each role and color scheme.
- A shadow token that draws nothing is
0 0 0 0 transparent, notnone. Components compose shadows in lists, andnoneis not valid inside one. - Answer a request for more contrast. The theme firms its lines and its two quiet inks under
prefers-contrast: more; a theme that sets its own lines writes that block too, as Paper does. Preserve the components' forced-color and reduced-motion behavior when changing recipes.
Check the result with keyboard focus, hover, press, disabled controls and invalid fields, in light and dark. Open a menu or dialog over the page to judge its edge and text against the actual backdrop. Repeat with reduced motion and forced colors: a theme that looks clear at rest can still hide a state or remove the only visible boundary.