Skip to content
FoundationsTheming

Theming

Matter's themes are CSS custom properties, scoped by data-theme attribute on <html>. No runtime theme provider, no JS theme switch math, no styled-components contexts. Light is the default; dark is data-theme="dark"; forced-colors is @media (forced-colors: active). Three surfaces, all driven by the cascade.

This page covers the theming model, the dark-mode strategy, the forced-colors contract, and how to customise the theme for an embedded consumer (Brex, Mercury, Gusto patterns).

Theming axes

Six independent dimensions resolve through the cascade. Components branch on none of them — they read tokens, and the attribute or media query decides which value the token holds.

AxisSelectorValuesNotes
data-theme[data-theme="…"] on <html>light (default) · darkOwns the foreground/background ramp. Pair with style.colorScheme for native UI.
data-density[data-density="…"] on <html>comfortable (default) · compactFlips control / input / pill heights and row gutters.
data-product[data-product="…"] on <html>dashboard · docs · marketingPer-product overlay tokens — surface tinting, hairline strength. Set once per route layout.
data-register[data-register="…"] on <html> or a scopeproduct · marketingTone register — typographic scale and motion warmth shift between the two registers.
data-brand-variant[data-brand-variant="…"] on <html>(default) · studios · capital · labsScaffold only. Selectors emit empty rules today; sub-brand overrides land here without component edits.
forced-colors@media (forced-colors: active)system-drivenReroutes brand and ring tokens to Canvas / CanvasText / AccentColor / etc. See Forced colors.
prefers-reduced-motion@media (prefers-reduced-motion: reduce)system-drivenDisables non-essential motion globally in base.css.

The first five compose freely: <html data-theme="dark" data-density="compact" data-product="dashboard" data-register="product"> resolves dark + compact + dashboard + product without any component branching. data-brand-variant is the newest axis — wired through the cascade today, occupied by future sub-brand colour overrides.

The model in one paragraph

The token surface in @matter/tokens ships CSS custom properties under three scopes: :root for light (the default), [data-theme="dark"] for dark, @media (forced-colors: active) for high-contrast. Every component reads its colors through var(--token-name). Switching themes is "set the attribute on <html>"; the cascade does the rest.

<html data-theme="light">  <!-- default -->
<html data-theme="dark">   <!-- dark -->

Components don't branch on theme. They consume tokens; tokens adapt.

How the cascade resolves

/* @matter/tokens/css/tokens.css */
:root {
  --fg: #0D0D0D;
  --bg: #FFFFFF;
  --surface-glass: rgba(255, 255, 255, 0.88);
}

[data-theme="dark"] {
  --fg: #FAFAF9;
  --bg: #0B0B0E;
  --surface-glass: rgba(20, 17, 13, 0.88);
}

@media (forced-colors: active) {
  :root {
    --fg: CanvasText;
    --bg: Canvas;
    --surface-glass: Canvas;
    /* …system colors propagate everywhere */
  }
}

A component renders the same JSX in every theme; the CSS values change.

Switching themes at runtime

The site's header theme switcher wires next-themes with attribute="data-theme". The inline <head> script in apps/design/app/layout.tsx reads localStorage and sets the attribute before paint, preventing flicker.

The minimum no-flicker pattern for a consumer:

<script>
  // Inline in <head>, before any CSS loads.
  (function () {
    var saved = localStorage.getItem('matter-design-theme');
    var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
    document.documentElement.style.colorScheme = theme;
  })();
</script>

Two attributes get set together: data-theme (token cascade) and style.colorScheme (browser UI — scrollbars, native form controls).

The color-scheme and theme-color pair

Two HTML signals beyond data-theme:

  • color-scheme on <html> drives browser-native UI — scrollbars, native dropdowns, form-control accents. Always set it together with data-theme.
  • <meta name="theme-color"> drives browser chrome (mobile address bar, PWA splash). Use media-paired meta tags:
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0B0B0E" media="(prefers-color-scheme: dark)" />

Modern browsers pick the matching tag based on the active scheme. Don't try to update the meta tag from JS — the static pair is simpler and works the same way.

Dark-mode strategy

Dark mode in Matter isn't an inversion of light. The dark theme has its own canonical values, designed against the same brand identity but tuned for darker ambient contrast.

SurfaceLightDark
--bg#FFFFFF#0B0B0E
--fg#0D0D0D#FAFAF9
--bg-elev#FFFFFF (no elevation contrast)#16161A (subtle lift)
--surface-glassrgba white 88%rgba ink 88%
Bloom variantsSaturated paintSame hue, adjusted lightness
Status pills (green / red / amber / indigo)Light tints + saturated foregroundsSaturated tints + light foregrounds

Every component renders correctly in both themes. Verify with the theme switcher in this site's header, or by setting data-theme="dark" on a preview locally.

Forced colors (Windows high-contrast)

When @media (forced-colors: active) matches, the operating system overrides authored colors with a system palette (Canvas, CanvasText, Highlight, HighlightText, ButtonFace, ButtonText, ButtonBorder, LinkText, VisitedText). Matter's strategy:

  1. Let the system win. Don't fight the override.
  2. Use semantic system colors as fallbacks. Foreground → CanvasText. Backgrounds → Canvas. Buttons → ButtonFace/ButtonText. Borders → ButtonBorder.
  3. Verify shape survives. Every component must remain identifiable in forced-colors. The rounded-corner radius, the spacing, the structural shape — these carry the identity when color is gone.

Forced-colors-specific overrides live in two layers. The canonical ring (--bg, --fg, --accent, …) is rerouted in @matter/tokens/css/forced-colors.css. The v1 brand tokens (--bg-page, --fg-heading, --matter-orange, --fg-faint, …) are rerouted in packages/brand/src/styles/forced-colors.css, imported last in the brand cascade so it wins over the legacy shadcn remap. Each component documents its forced-colors behaviour in its accessibility section.

Brand customisation for embedded consumers

Matter is designed to be embeddable inside partner products (Brex, Mercury, Gusto). The embedded consumer wants Matter components rendered in their brand identity. The customisation surface:

/* Consumer overrides — one CSS file, loaded after @matter/tokens. */
:root {
  --matter-peach: #FF5A36;     /* partner brand color */
  --matter-orange: #E04A2C;
  --action-primary: #FF5A36;
  --action-primary-hover: #E04A2C;
  /* …override any token in the surface */
}

/* Don't override every token — only the brand-identity ones. */

The override is at the consumer layer; Matter's tokens cascade through. A partner can re-skin the bloom palette, the action color, the focus ring — without touching component source.

Matter's drift gate enforces that components only consume tokens, never literal values. This is what makes brand customisation work — every component is a thin wrapper over the token surface.

What never to override

  • The accessibility contract. Don't override forced-colors values; let the system win.
  • The motion contract. Durations and easings are part of the brand identity; per-consumer overrides drift the perceived performance characteristics.
  • The voice rules. Voice lives in copy and prompts, not tokens — not a theming surface.

Multi-theme strategies

Some consumers want both their brand identity and the Matter palette available. Two patterns work:

  1. Scope by attribute. [data-product="partner"] { --matter-peach: … }. Pages tagged data-product="partner" get the partner palette; pages tagged data-product="matter" (or untagged) get Matter's.
  2. Scope by route. Set data-product on the route layout. Matter's design site itself uses this pattern: <html data-product="design">.

The second pattern is preferred when the entire app is partner-branded. The first when a page may host both surfaces.

Test matrix

ThemeDensityBrowserTest
LightComfortableChromeDefault render — pixel parity with Figma
LightCompactChromeDensity toggle on dashboard surfaces
DarkComfortableChromeToggle header → dark; verify every component re-paints
DarkCompactChromeCombined toggle
Forced colorsEitherChrome (or Edge with high-contrast on)macOS Increase Contrast / Windows high-contrast
LightEitherSafari iOSprefers-color-scheme: light from OS
DarkEitherSafari iOSprefers-color-scheme: dark from OS

Every component MDX page renders the first three previews automatically; the rest are on you to walk through manually.

When to add a token vs. override

SituationAction
Consumer needs a specific brand colorOverride the existing token at the consumer layer
Consumer needs a color that doesn't existPropose a new token via Governance
Component renders wrong in darkBug in the component or in the dark cascade — file a regression
Forced-colors mode looks brokenBug in the forced-colors CSS — file a regression

The default is to compose; new tokens are a deliberate addition with the proposal workflow attached.

On this page