Navigation

Authoring

Theme tokens

Define shared design values, see how token edits affect consumers, and choose local overrides, static delivery and inline substitution.

On this page

#Overview

Define a token when several elements should change together: card spacing, an action color or the width of a reading column. Put it in @theme after the existing @import '@master/css' in your app stylesheet, then use the matching class in markup.

Three shared values, one card

@theme {  --color-brand: #4f46e5;  --spacing-card: 1.5rem;  --radius-card: 0.5rem;}
Generated CSS
@layer theme {  :root,  :host {    --color-zinc-30: oklch(78% .0025 265);    --color-zinc-40: oklch(68.6% .0028 265);    --color-zinc-60: oklch(48.6% .003 265);    --color-zinc-70: oklch(39.8% .0028 265);    --color-zinc-90: oklch(24.6% .0022 265)  }  :root,  :host {    --color-surface-raised: light-dark(var(--color-white), var(--color-zinc-90));    --color-text-body: light-dark(var(--color-zinc-70), var(--color-zinc-30));    --color-text-muted: light-dark(var(--color-zinc-60), var(--color-zinc-40))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-weight-regular: 400;    --font-size-xs: .75rem;    --font-size-sm: .875rem;    --font-size-lg: 1.125rem;    --text-xs: var(--font-size-xs);    --text-xs--line-height: 1.5;    --text-xs--letter-spacing: 0;    --text-sm: var(--font-size-sm);    --text-sm--line-height: 1.5;    --text-sm--letter-spacing: 0;    --text-lg: var(--font-size-lg);    --text-lg--line-height: 1.5;    --text-lg--letter-spacing: 0;    --spacing-xs: .5rem;    --spacing-sm: .75rem;    --container-sm: 24rem  }  :root,  :host {    --shadow-sm: 0 0 0 1px light-dark(oklch(0% 0 none / .04), oklch(100% 0 none / .05)), 0 1px 1px -.5px light-dark(oklch(0% 0 none / .045), oklch(0% 0 none / .3)), 0 2px 2px -1px light-dark(oklch(0% 0 none / .04), oklch(0% 0 none / .26)), 0 4px 4px -2px light-dark(oklch(0% 0 none / .04), oklch(0% 0 none / .24))  }  :root,  :host {    --color-brand: #4f46e5;    --spacing-card: 1.5rem;    --radius-card: .5rem  }}@layer utilities {  .text-lg {    font-size: var(--text-lg);    line-height: var(--text-lg--line-height, normal);    letter-spacing: var(--text-lg--letter-spacing, normal)  }  .text-sm {    font-size: var(--text-sm);    line-height: var(--text-sm--line-height, normal);    letter-spacing: var(--text-sm--letter-spacing, normal)  }  .text-xs {    font-size: var(--text-xs);    line-height: var(--text-xs--line-height, normal);    letter-spacing: var(--text-xs--letter-spacing, normal)  }  .items-center {    align-items: center  }  .bg-brand {    background-color: var(--color-brand)  }  .bg-surface-raised {    background-color: var(--color-surface-raised)  }  .r-card {    border-radius: var(--radius-card)  }  .pill {    border-radius: calc(infinity * 1px)  }  .shadow-sm {    box-shadow: var(--shadow-sm)  }  .fg-text-body {    color: var(--color-text-body)  }  .fg-text-muted {    color: var(--color-text-muted)  }  .flex {    display: flex  }  .font-regular {    font-weight: var(--font-weight-regular)  }  .gap-xs {    gap: var(--spacing-xs)  }  .h\(8\) {    height: calc(8 * 1px)  }  .m\(0\) {    margin: calc(0 * 1px)  }  .mx-auto {    margin-inline: auto  }  .mb-xs {    margin-bottom: var(--spacing-xs)  }  .mt-sm {    margin-top: var(--spacing-sm)  }  .max-w-sm {    max-width: var(--container-sm)  }  .p-card {    padding: var(--spacing-card)  }  .w\(8\) {    width: calc(8 * 1px)  }}
p-card and r-card set the shared padding and radius. The small marker uses bg-brand to make the color token visible without changing the card's neutral surface.

p-card generates padding: var(--spacing-card); the browser resolves the custom property on the card. Change the token when every consumer should change together, and use a local value when the change belongs to one element.

#Change one value across its consumers

--spacing-card can supply both a card's padding and a list's gap. Change its value from 1.5rem to 2rem, and both uses change without editing their markup:

<article class="p-card">Field notes</article><ul class="flex gap-card">  <li>Draft</li>  <li>Ready</li></ul>

One shared value changes padding and gap together

The markup stays the same in both columns. Measurements assume a 16px root font size; the highlighted regions show padding and gap at their actual relative sizes.

Text description
  1. Before. At a root font size of 16px, --spacing-card: 1.5rem supplies 24px of padding through p-card and a 24px gap through gap-card.
  2. After. Changing the token to 2rem increases both measurements to 32px without editing the classes.
  3. Choose the coupling. Use one token only when these spaces should change together. Separate design decisions need separate tokens even if their current values match.

This coupling is a design decision. If cards should become roomier while list spacing stays the same, give those decisions separate tokens. Sharing a number today does not mean two parts of the interface should always change together.


#Name tokens by namespace

The token prefix chooses its namespace, and each class family reads the namespaces it understands. --spacing-card serves p-card, m-card and gap-card; --radius-card serves r-card; --container-panel serves max-w-panel. The same key can mean different values in different families: p-lg reads spacing and r-lg reads radius.

index.css
@theme {  --color-text-action: var(--color-brand);  --font-family-sans: Geist, ui-sans-serif, system-ui, sans-serif;  --container-panel: 28rem;}

Only @theme declarations register class tokens; a native custom property elsewhere does not. Name a token after the shared decision, such as brand or card, not the first component that used it. Redefine a preset key, such as --spacing-lg, to change it for the whole project. See the family index for which namespaces each class reads, and Variables and modes for resolution.


#Alias and override tokens

Use var() when one token should follow another. Aliases resolve before inheritance: if --color-text-action is declared at the root, overriding --color-brand in a descendant does not rebind the inherited alias. Redeclare the alias in that scope.

A local brand and an inherited alias

@theme {  --color-brand: light-dark(#1d4ed8, #93c5fd);  --color-text-action: var(--color-brand);}@layer components {  [data-brand="plum"] {    --color-brand: light-dark(#86198f, #f0abfc);  }  [data-rebind-action] {    --color-text-action: var(--color-brand);  }}
Generated CSS
@layer components {  [data-brand="plum"] {    --color-brand: light-dark(#86198f, #f0abfc);  }  [data-rebind-action] {    --color-text-action: var(--color-brand);  }}@layer theme {  :root,  :host {    --color-zinc-90: oklch(24.6% .0022 265)  }  :root,  :host {    --color-surface-raised: light-dark(var(--color-white), var(--color-zinc-90))  }  :root,  :host {    --color-white: oklch(100% 0 none)  }  :root,  :host {    --font-weight-regular: 400;    --font-size-sm: .875rem;    --font-size-lg: 1.125rem;    --text-sm: var(--font-size-sm);    --text-sm--line-height: 1.5;    --text-sm--letter-spacing: 0;    --text-lg: var(--font-size-lg);    --text-lg--line-height: 1.5;    --text-lg--letter-spacing: 0;    --radius-lg: .5rem;    --spacing-sm: .75rem;    --spacing-md: 1rem;    --spacing-lg: 1.5rem  }  :root,  :host {    --color-brand: light-dark(#1d4ed8, #93c5fd);    --color-text-action: var(--color-brand)  }}@layer utilities {  .text-lg {    font-size: var(--text-lg);    line-height: var(--text-lg--line-height, normal);    letter-spacing: var(--text-lg--letter-spacing, normal)  }  .text-sm {    font-size: var(--text-sm);    line-height: var(--text-sm--line-height, normal);    letter-spacing: var(--text-sm--letter-spacing, normal)  }  .bg-surface-raised {    background-color: var(--color-surface-raised)  }  .r-lg {    border-radius: var(--radius-lg)  }  .fg-brand {    color: var(--color-brand)  }  .fg-text-action {    color: var(--color-text-action)  }  .font-regular {    font-weight: var(--font-weight-regular)  }  .m\(0\) {    margin: calc(0 * 1px)  }  .mt-md {    margin-top: var(--spacing-md)  }  .mt-sm {    margin-top: var(--spacing-sm)  }  .mb\(0\) {    margin-bottom: calc(0 * 1px)  }  .p-lg {    padding: var(--spacing-lg)  }}
Both action labels use fg-text-action. The first inherits the alias computed at the root; the second redeclares it where --color-brand is plum.

An inherited alias keeps its computed value

The blue and violet marks identify the two value paths. This is a scope diagram, not an extra theme switch.

Text description

The root resolves an action alias to blue. A child changes brand to plum: the inherited alias remains blue until it is redeclared in that child.

For a color that follows light and dark modes, give the token a light-dark() value and keep its name mode-neutral, such as --color-surface-card. See Color for activating modes.


#Choose token delivery and value substitution

The two modifiers answer different questions:

NeedChooseResult
Emit values when the project uses themOrdinary @themeOn-demand declarations; classes keep token references.
Keep values even without a discovered consumer@theme staticToken declarations remain available, for example to an external integration.
Resolve a referenced variable on each styled element@theme inlineGenerated declarations substitute the token value once.

Use static for a value read by an integration that static discovery cannot see:

@theme static { --color-integration-brand: #4f46e5; }

Use inline to bridge a native variable that should resolve on the element using the class:

@theme inline { --color-brand: var(--app-brand); }:root { --app-brand: #4f46e5; }[data-theme="ocean"] { --app-brand: #0369a1; }
<section data-theme="ocean">  <div class="bg-brand">Ocean background</div></section>

Here bg-brand emits background-color: var(--app-brand), so the inner element reads the ocean value inherited from its section. The substitution removes the intermediate --color-brand reference; it does not copy a fixed color into every element.

Combine the modifiers with @theme static inline when you need both behaviors. Inline substitution applies to classes and @apply; handwritten native CSS keeps its authored references. See the theme contract.


Choose retention and substitution independently

static controls retention; inline controls substitution. Using one does not imply the other.

Text description

Two perpendicular axes separate keeping a token without a consumer from substituting its value into generated declarations.

#Define motion tokens

An --animate-* token is a complete animation shorthand, so it can combine the keyframe name with duration and easing tokens. Write the keyframes as native CSS beside it:

Motion follows a real action

The dialog enters once on opening. Escape or Close details returns focus to its trigger. Reduced motion opens it immediately. Its modal boundary stays inside this preview.
index.css
@theme {  --duration-enter: 180ms;  --easing-emphasized: cubic-bezier(.16, 1, .3, 1);  --animate-dialog-in: dialog-in var(--duration-enter) var(--easing-emphasized) both;}@keyframes dialog-in {  from { translate: 0 0.5rem; opacity: 0; }}
<dialog class="animate-dialog-in@motion-safe">…</dialog>

The token has no iteration count, so the entrance runs once; preset animate-* tokens repeat. @motion-safe keeps the dialog still when reduced motion is requested. Keyframes stay in their stylesheet by default; with @prune native, a referenced animation token retains its keyframes. Add @safelist keyframes "dialog-in"; for a name that only JavaScript applies. See Motion and the keyframe contract.