Navigation

v0.0.4
Customizing

Default theme

Moonstone and obsidian: the default materials, token roles and implementation values in Master UI.

On this page

Master UI's default theme has one material language. Light mode is moonstone, a clean zinc pearl. Dark mode is obsidian, a matte zinc glass whose surfaces step apart and keep a legible edge. Both are lit by one source above and slightly to the left, so every raised face catches a line of light on its top edge and settles on a short shadow that fades out.

The character is restrained: ink rather than color, half-pixel contours, corners that nest and depth that changes with state. Detail comes from light, not decoration, and no edge is harder than it needs to be to be found.

Use the sections below to choose a surface, line, text or depth role for your interface. The values describe the default theme in the current package; Theming shows how to override them or build another material.

Moonstone and obsidian

<div class="tray">  <div class="sheet flex flex-col gap-md p-lg">    <div class="flex items-center justify-between gap-md">      <span class="text-sm fg-text-strong">Production</span>      <span class="badge badge-success badge-dot">Ready</span>    </div>    <input class="input" value="atlas.dev" aria-label="Domain">    <div class="flex items-center justify-between gap-md">      <label class="check">        <input type="checkbox" role="switch" class="switch" checked>Preview builds</label>      <button type="button" class="btn btn-primary">Deploy</button>    </div>  </div></div>
Toggle Theme to compare moonstone and obsidian. Both come from the same stylesheet.

State and depth

State changes depth on the same control. Rest, hover and active use the raised, raised-hover and pressed shadow tokens respectively; active also takes an inset face. The default --lift moves the button up 0.5px on hover-capable pointers and down 0.5px while active. Cross-sections exaggerate depth. Use the live previews to try the actual interaction in both color schemes.

#Principles

Design explains the decisions behind the default materials, hierarchy, geometry and interaction states. Use that guidance to make design choices; this page documents the tokens and recipes implemented by @master/ui.

packages/ui/theme.css is the executable source of these values. Component styles read those tokens, and a theme such as Paper can replace them without rewriting the components.

#Surfaces

Surfaces form one ladder. Each step is a role, not a color: pick the step by what the surface does, and the theme resolves its value in light and dark.

The material ladder

<div class="tray">  <p class="m(0) px-md py-sm text-xs fg-text-muted">Tray</p>  <div class="sheet flex flex-col gap-md p-lg">    <p class="m(0) text-xs fg-text-muted">Raised sheet</p>    <div class="well p-md text-xs fg-text-muted">Inset well</div>    <div class="sheet sheet-floating p-md text-xs fg-text-muted">Floating</div>  </div></div>
TokenRoleMoonstoneObsidian
--color-surface-baseThe page canvas98.6%19.4%
--color-surface-trayA recessed holder for sheets96%17.4%
--color-surface-raisedA raised face: cards, buttons100%24.2%
--color-surface-raised-lowThe lower end of a raised face's gradient99.3%22.8%
--color-surface-insetA well: fields, tracks96.6%16.4%
--color-surface-inset-hoverThe same well under the pointer95.8%15.4%
--color-surface-floatingPopovers and menus over content100%26.4%
--color-surface-chipNeutral chips and badges96.5%white 7%
--color-surface-inverseInk: primary buttons, checked controls22%95%
--color-surface-scrimThe backdrop behind a modal18% at 20% opacityblack at 55% opacity

Values are OKLCH lightness unless opacity is stated. A raised face is a top-to-bottom gradient from raised to raised-low, which @apply --surface-sheet writes for you. Choose a role by purpose: floating distinguishes a menu over a sheet, while inset marks a field within it. The color token does not position the element or give it a shadow.

#Lines

Lines are neutral ink alphas on moonstone and white alphas on obsidian. Two of them are 1px rules. The other three are contours, drawn as half-pixel rings with a zero-blur box-shadow, which takes no layout space; their alphas are tuned for that width, so a contour reads as fine rather than faint.

TokenMoonstoneObsidianDrawn atUse for
--color-line-hair6.5%7.5%1pxQuiet separators inside a surface
--color-line-divider12%13%1pxRules between sections, tab rows
--color-line-contour12%14%.5pxThe outline of every surface
--color-line-strong20%23%.5pxHovered contours, switch tracks, keycaps
--color-line-mark32%36%.5pxCheckboxes, a focused field's contour

A control that must be found at a glance takes a firmer contour than a surface does: a field stacks two contour rings, and a checkbox uses the mark.

#Text

Text is a soft ink, never pure black or white.

TokenUse for
--color-text-strongHeadings, labels and values
--color-text-bodyRunning text
--color-text-mutedHints, captions and inactive options
--color-text-disabledDisabled controls
--color-text-inverseText on ink
--color-focusFocus outlines

#Elevation

Elevation is the order of depth, from a pressed well to an overlay. A raised face combines an inset highlight on its top edge, one half-pixel contour and three to six falling shades.

Elevation

<div class="p-lg r-lg bg-surface-inset shadow-pressed text-xs fg-text-muted">pressed</div><div class="p-lg r-lg bg-surface-raised shadow-flat text-xs fg-text-muted">flat</div><div class="p-lg r-lg bg-surface-raised shadow-raised text-xs fg-text-muted">raised</div><div class="p-lg r-lg bg-surface-raised shadow-raised-hover text-xs fg-text-muted">raised-hover</div><div class="p-lg r-lg bg-surface-raised shadow-lifted text-xs fg-text-muted">lifted</div><div class="p-lg r-lg bg-surface-floating shadow-floating text-xs fg-text-muted">floating</div>
TokenUse for
--shadow-pressedWells and pressed controls
--shadow-flatA surface with one contour and no lift
--shadow-raisedButtons, chips and small cards at rest
--shadow-raised-hoverThe same under the pointer, with a firmer contour
--shadow-liftedCards and panels
--shadow-floatingMenus and popovers
--shadow-overlayDialogs

For example, --shadow-raised layers 0 1px 2px -1px, 0 2px 4px -2px and 0 4px 8px -4px beneath its highlight and contour. Higher elevations use a wider sequence of offsets. The negative spread keeps the shadow close to the object, while the blur softens its edge. These tokens draw depth; stacking order still comes from the element's layout and platform behavior.

#Ink

Ink is a face filled with the inverse color: a primary button, a checked checkbox, a sent message, the one inverse sheet of a page. Every ink face catches the same line of light on its top edge and takes the same hairline around it, so they read as one material at any size.

TokenRole
--color-surface-inverse-high, --color-surface-inverse-lowThe fall of light on a sheet of ink
--color-ink-high, --color-ink-lowThe rounder fall on a control of ink, at rest
--color-ink-high-hover, --color-ink-low-hover, --color-ink-pressedThe same control under the pointer and under a press
--color-ink-highlightThe line of light on the top edge of any ink face
--color-ink-edgeThe hairline around it
--color-surface-tipA tooltip: ink on moonstone, a lighter slab on obsidian
--shadow-ink, --shadow-ink-hover, --shadow-ink-pressedThe depth of a control of ink in each state
--shadow-ink-markA small checked fill: a checkbox, a radio, a chosen day
--shadow-ink-well, --shadow-thumb-on-inkA filled track, and the thumb that rides it
--shadow-tipA tooltip and a chart's value

A hued control (brand, a destructive confirmation) mixes its face, its edge and its glow from its own hue; --color-tone-highlight and --color-tone-highlight-soft are the light it catches.

#Thumbs, pictures and supplied colors

TokenRole
--color-thumb-high, --color-thumb-lowThe pearl thumb of a switch, a slider and a comparison
--color-thumb-on-high, --color-thumb-on-lowThe thumb of a switch that is on
--color-line-image, --color-line-image-strongThe inward line a picture and an avatar take, so neither bleeds into its surface
--color-line-on-color, --color-highlight-on-colorThe edge and the light on a color the page supplies, such as a swatch
--color-on-image, --color-on-image-shadeWhat is drawn over a picture: white with a dark shade, in both themes
--color-mark, --color-mark-underlineA highlighter stroke and its underline form
--color-sheenThe light that passes over a skeleton
--color-focus-haloThe soft halo a focused field wears outside its contour

#Shape

Radii are roles, like surfaces. A chip is a small mark, a control is something pressed or typed in, and a surface is a sheet. Because the tokens are in @theme, they are utilities too: r-control uses the control's base radius. Components additionally apply the continuous-corner shape and scale described below; a radius utility alone does not reproduce that treatment.

TokenValueUsed by
--radius-chip6pxBadges, keycaps, checkboxes, skeletons
--radius-control-sm8pxSmall buttons and fields, menu rows, options, segmented thumbs
--radius-control10pxButtons, fields, wells
--radius-control-lg12pxLarge buttons and fields, alerts, choices, toasts
--radius-surface16pxSheets, cards

A holder has no radius of its own. It derives its corner from what it holds: the inner radius plus the inset between them. A tray is a sheet's corner plus 4px, a menu is a row's corner plus 4px, a segmented track is its thumb's corner plus its inset, and a button inside a field is the field's corner minus 4px. The gap therefore keeps one width around the bend. Only pills, dots and avatars are rounder.

Nested corners

<div class="tray max-w(360) mx-auto">  <div class="sheet flex flex-col gap-md p-lg">    <div class="input-group">      <input class="input-group-field" value="atlas.dev" aria-label="Domain">      <button type="button" class="btn">Verify</button>    </div>    <div class="segmented" role="radiogroup" aria-label="Environment">      <label class="segmented-item">        <input type="radio" class="segmented-input" name="environment" checked>Production</label>      <label class="segmented-item">        <input type="radio" class="segmented-input" name="environment">Preview</label>    </div>  </div></div>

#Continuous corners

border-radius draws a quarter circle. Where a quarter circle meets a straight edge, the curvature jumps from zero to its full value at once, and the eye reads that joint as a shoulder. A squircle, drawn with corner-shape: squircle, starts turning earlier and tightens gradually, so the edge flows into the corner.

Components draw squircles where the browser supports corner-shape, and quarter circles elsewhere. A squircle hugs its corner more closely than a circle of the same radius, so a component multiplies its radius by --corner-scale to keep the same apparent roundness: 1.6 with squircles, 1 without. Both values are set once in the theme, and every component reads them.

PropertyWith corner-shapeWithout
--corner-shapesquircleround
--corner-scale1.61

Pills, dots, avatars and thumbs stay true circles. Theming shows how to keep circular corners everywhere.

#Type

The theme does not download a font. The documentation uses Geist, and controls read the preset's font-family tokens. Load the font in your app before selecting it; the site's typography settings are:

app.css
@theme {  --font-sans: 'Geist';  --font-feature-sans: 'ss02', 'ss03', 'ss04', 'ss06', 'ss07', 'ss08';}body {  font-weight: 350;}

Control labels use --font-weight-control (460), a light medium that reads clearly without looking heavy. Headings stay regular: size and ink carry the steps.

#Motion

One easing family keeps interaction quick and explanation calm, and one length sets how far a control travels: --lift is the half pixel a raised control rises under the pointer and sinks under a press. A card rises twice as far. Set it to 0px for a theme without travel; reduced motion already does.

TokenValueUse for
--easing-glidecubic-bezier(.22, 1, .36, 1)Every state change
--duration-press60msPressing a control
--duration-quick160msHover and focus; popovers, menus and tooltips; any exit
--duration-base280msThumbs and panels that move; a dialog arriving
--lift.5pxHow far a raised control rises and sinks

Overlays write both directions in CSS. @starting-style gives the frame an element arrives from, and display and overlay transition with allow-discrete, so the exit plays before the element leaves the top layer. An exit is shorter than its entrance. With reduced motion, travel and scale are removed and the fade stays.

#Status and brand

--color-info, --color-success, --color-warning and --color-danger are the only hues the interface uses to speak. Each is tuned to read as text on its theme.

The brand color is the one hue that is not a status. It appears on btn-brand, once per view.

TokenValueUse for
--color-brandvar(--color-indigo-50)The face of a brand surface
--color-brand-inknear whiteText on a brand surface
--color-brand-textindigo-60 on moonstone, indigo-30 on obsidianBrand-colored text on the page

A brand surface mixes its edge, its light and its glow from --color-brand, so overriding that one token retunes it. Set --color-brand-ink to a dark ink if your brand color is light.

Status and brand

<span class="badge badge-info badge-dot">Info</span><span class="badge badge-success badge-dot">Success</span><span class="badge badge-warning badge-dot">Warning</span><span class="badge badge-danger badge-dot">Danger</span><button type="button" class="btn btn-brand btn-sm">Upgrade</button>

#Color schemes

Colors that differ between schemes use light-dark(); some highlights and colors drawn over images stay the same in both. The theme follows the element's color-scheme. The Master CSS preset sets color-scheme: light dark on the root and lets you pin a scheme for the page or for one subtree:

<html data-theme="dark">  …  <aside data-theme="light">A moonstone panel on an obsidian page</aside></html>

There is no second stylesheet for dark mode and no dark-only class on components.

#Customize

See Theming to override tokens or build a complete theme, and Customization to choose between a utility, a token, a component property and owning the source.