Navigation

v0.0.4
Customizing

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.

app.css
@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:

app.css
: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.

DecisionTokens
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.

app.css
@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.

@master/ui/themes/paper.css
/* * Paper: a second theme, and the proof that the first one is only a theme. * * Moonstone is lit from above: faces fall from light to shade, edges are found by a highlight and a shadow, * controls rise and sink. Paper has no light source. Every face is one flat tone, every edge is a ruled * hairline, nothing travels, corners are tighter and circular, and the neutrals are warm instead of zinc. * It is written with tokens alone, plus the one recipe a token cannot reach (a toned control mixes its face * from its own hue), and it changes no component. Import it after the library: * *   @import '@master/css'; *   @import '@master/ui'; *   @import '@master/ui/themes/paper.css'; * * Copy this file to start a theme of your own: the tokens below are the ones a theme decides. */@theme {  /* Surfaces: warm paper and warm slate. A raised face has no fall, so its two ends are one tone. */  --color-surface-base: light-dark(oklch(98.4% .004 90), oklch(18.5% .004 80));  --color-surface-tray: light-dark(oklch(96.2% .005 90), oklch(16.5% .004 80));  --color-surface-raised: light-dark(oklch(99.6% .002 90), oklch(22.5% .005 80));  --color-surface-raised-low: var(--color-surface-raised);  --color-surface-inset: light-dark(oklch(96.8% .005 90), oklch(15.5% .004 80));  --color-surface-inset-hover: light-dark(oklch(95.6% .006 90), oklch(14.5% .004 80));  --color-surface-floating: light-dark(oklch(99.6% .002 90), oklch(25% .005 80));  --color-surface-chip: light-dark(oklch(95.4% .006 90), oklch(100% 0 none / .08));  --color-surface-inverse: light-dark(oklch(24% .006 80), oklch(94% .005 90));  --color-surface-inverse-high: var(--color-surface-inverse);  --color-surface-inverse-low: var(--color-surface-inverse);  --color-surface-tip: light-dark(var(--color-surface-inverse), oklch(32% .006 80));  /* Lines carry every edge here, so each is a step firmer than under a light. */  --color-line-hair: light-dark(oklch(24% .006 80 / .1), oklch(100% 0 none / .11));  --color-line-contour: light-dark(oklch(24% .006 80 / .22), oklch(100% 0 none / .22));  --color-line-divider: light-dark(oklch(24% .006 80 / .16), oklch(100% 0 none / .16));  --color-line-strong: light-dark(oklch(24% .006 80 / .36), oklch(100% 0 none / .36));  --color-line-mark: light-dark(oklch(24% .006 80 / .56), oklch(100% 0 none / .56));  --color-text-strong: light-dark(oklch(22% .006 80), oklch(95% .005 90));  --color-text-body: light-dark(oklch(38% .007 80), oklch(81% .006 90));  --color-text-muted: light-dark(oklch(48% .008 80), oklch(69% .007 90));  --color-text-disabled: light-dark(oklch(68% .007 80), oklch(48% .007 90));  --color-text-inverse: light-dark(oklch(98.4% .004 90), oklch(18.5% .004 80));  --color-focus: light-dark(oklch(24% .006 80), oklch(94% .005 90));  --color-focus-halo: light-dark(oklch(24% .006 80 / .12), oklch(100% 0 none / .14));  /* No light: no highlight on a top edge, no lip under a recess, no falling shade. */  --color-highlight: transparent;  --color-lip: transparent;  --color-shade-1: transparent;  --color-shade-2: transparent;  --color-shade-3: transparent;  --color-shade-4: transparent;  --color-shade-5: transparent;  /* Ink is one flat tone at rest, a step lighter under the pointer and a step darker under a press. */  --color-ink-high: var(--color-surface-inverse);  --color-ink-low: var(--color-surface-inverse);  --color-ink-high-hover: light-dark(oklch(32% .007 80), oklch(100% 0 none));  --color-ink-low-hover: var(--color-ink-high-hover);  --color-ink-pressed: light-dark(oklch(18% .005 80), oklch(87% .006 90));  --color-ink-highlight: transparent;  --color-ink-edge: transparent;  --color-tone-highlight: transparent;  --color-tone-highlight-soft: transparent;  /* A thumb is a flat disc with a ruled edge. */  --color-thumb-high: oklch(100% 0 none);  --color-thumb-low: oklch(100% 0 none);  --color-thumb-on-high: light-dark(oklch(100% 0 none), oklch(22.5% .005 80));  --color-thumb-on-low: var(--color-thumb-on-high);  /* Brand: a forest green, so the one hued moment is plainly not the default indigo. */  --color-brand: oklch(50% .13 155);  --color-brand-ink: oklch(99% .01 155);  --color-brand-text: light-dark(oklch(44% .12 155), oklch(80% .12 155));  /*   * Elevation is a rule, not a shadow. What floats over content (a menu, a dialog) keeps one soft shade,   * because a rule alone cannot say that something is above the page.   */  --shadow-pressed: inset 0 0 0 .5px var(--color-line-strong);  --shadow-flat: 0 0 0 .5px var(--color-line-contour);  --shadow-raised: 0 0 0 .5px var(--color-line-strong);  --shadow-raised-hover: 0 0 0 .5px var(--color-line-mark);  --shadow-lifted: 0 0 0 .5px var(--color-line-contour);  --shadow-floating: 0 0 0 .5px var(--color-line-strong), 0 12px 32px -16px light-dark(oklch(24% .006 80 / .28), oklch(0% 0 none / .6));  --shadow-overlay: 0 0 0 .5px var(--color-line-strong), 0 32px 64px -32px light-dark(oklch(24% .006 80 / .36), oklch(0% 0 none / .7));  --shadow-ink: 0 0 0 0 transparent;  --shadow-ink-hover: 0 0 0 0 transparent;  --shadow-ink-pressed: 0 0 0 0 transparent;  --shadow-ink-mark: 0 0 0 0 transparent;  --shadow-ink-well: 0 0 0 0 transparent;  --shadow-ink-top: 0 0 0 0 transparent;  --shadow-tip: 0 0 0 .5px light-dark(transparent, var(--color-line-strong));  --shadow-thumb-on-ink: 0 0 0 .5px light-dark(oklch(24% .006 80 / .3), oklch(0% 0 none / .5));  /* Shape: tighter, and circular in every browser (see the corner block below). */  --radius-chip: .1875rem;  --radius-control-sm: .25rem;  --radius-control: .3125rem;  --radius-control-lg: .375rem;  --radius-surface: .5rem;  --font-weight-control: 500;  /* Nothing rises or sinks. */  --lift: 0px;}@layer theme {  :root, :host {    --corner-shape: round;    --corner-scale: 1;  }  /* More contrast, when the reader asks for it: the rules that carry every edge here are firmer still. */  @media (prefers-contrast: more) {    :root, :host {      --color-line-hair: light-dark(oklch(24% .006 80 / .22), oklch(100% 0 none / .24));      --color-line-contour: light-dark(oklch(24% .006 80 / .4), oklch(100% 0 none / .42));      --color-line-divider: light-dark(oklch(24% .006 80 / .34), oklch(100% 0 none / .36));      --color-line-strong: light-dark(oklch(24% .006 80 / .56), oklch(100% 0 none / .58));      --color-line-mark: light-dark(oklch(24% .006 80 / .74), oklch(100% 0 none / .76));      --color-text-muted: light-dark(oklch(38% .008 80), oklch(80% .007 90));      --color-text-disabled: light-dark(oklch(55% .007 80), oklch(62% .007 90));    }  }}/* * A toned control mixes its face from its own hue, which a root token cannot do, so this theme writes the * recipe again: one flat hue, a step lighter under the pointer and a step darker under a press. */@mixin --control-tone {  color: var(--tone-ink);  background: var(--tone);  box-shadow: 0 0 0 0 transparent;}@mixin --control-tone-hover {  background: color-mix(in oklab, var(--tone), white 10%);  box-shadow: 0 0 0 0 transparent;}@mixin --control-tone-pressed {  background: color-mix(in oklab, var(--tone), black 12%);  box-shadow: 0 0 0 0 transparent;}

#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.css

Import your file after the library, then edit its tokens and recipes:

app.css
@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.

MixinWhat it drawsPaper
--surface-sheetA raised faceFlat, through --color-surface-raised-low
--surface-inkA sheet of ink: an inverse sheet, a sent messageFlat, through the -inverse-high and -low tokens
--control-ink, -hover, -pressedThe primary button's face and shadow in each stateFlat, through the ink tokens
--control-tone, -hover, -pressedA hued control, mixed from the control's own --toneRedefined: one flat hue
--surface-thumbThe thumb of a switch and a sliderFlat, through the thumb tokens
--focus-ringKeyboard focusUnchanged
--on-inkWhat an ink surface tells the inline chips it holdsUnchanged
--anchored, --popWhere a popover stands, and how it enters and leavesUnchanged

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:

app.css
@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, not none. Components compose shadows in lists, and none is 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.