Design System API · concept explainer

Journey of a design token

A module says what it needs. An author decides how it looks. Drupal works out the answer once per theme and writes it down. The browser reads the right line for each part of the page.

Module developer · onceDeclare

“My card has a border. By default, use the shared border colour.”

Site author · any timeFill

“On our Mercury site, make it navy. In dark mode, light navy.”

Drupal · once per theme, cachedResolve

Follows every reference to a plain value and writes one CSS block per mode.

Browser · every elementPick

The cascade picks the light, dark or high-contrast block that applies here.

The concept in four ideas

Who owns what

Vocabulary

The project owns a shared vocabulary. Drupal core stores it.

  • The shared names (color.border.default, color.bg.surface, space.m, …) are the project’s contract. design_system_api ships them with default values, including values for dark mode and more contrast.
  • Core supplies the file format and storage: the Design Tokens API with DTCG *.tokens.yml files.
  • Authors can add their own primitives, such as a brand palette (brand.navy.700). Primitives exist only while Drupal resolves values and never reach the page.
Modules

Modules name what they use. They never choose a value.

  • Module CSS reads its own component tokens (--ds-media-card-border) and shared tokens (--ds-space-m).
  • It never uses primitives or literal values such as #c8102e. A linter reports them.
  • The Media Library pilot does both: --ds-media-card-border-selected for its own parts, --ds-color-border-default and --ds-space-m where the shared value fits.
Mapping

Each component token says which shared token it follows.

  • This is the suggested mapping, declared in metadata: media-card.border: $value: "{color.border.default}" in the module’s *.tokens.yml (or an SDC component.yml).
  • It makes the component look right under every theme with zero setup.
  • It is only a default. An author can override it per theme and mode. It may point at shared tokens only, never at primitives or another module’s tokens.
Resolution

Drupal resolves once per theme. The browser picks per element.

  • For each token, the highest layer that sets it wins: author, then theme adapter, then defaults (contract and suggested mappings).
  • Drupal follows every reference in the same mode, turns primitives into plain values, and caches the result per theme. This doesn’t happen on every request.
  • Light, dark and high contrast are all sent, each as a CSS block. The browser picks, so a dark panel inside a light page just works.
  • The only reference left for the browser is an adapter’s pointer to the theme’s own variable, such as var(--admin-color-border). Try “Default Admin” in the explorer below.
Interactive · the real Media Library pilot token

Follow one border colour

Pick a theme and a mode. The trace shows every hop from the module’s CSS to a pixel colour, and where that hop happens.

Theme (server-side context)

Mode (page-side)
Contrast (page-side)
Author override
    What the page shows
    harbour-at-dusk.jpg
    Stylesheet Drupal generates for this theme (all modes, highlighted = applies here)
    
          
    What “runtime” means here

    Two moments of resolution

    Tokens are resolved in two places. Nothing resolves at render time in PHP. Each page reuses the cached stylesheet.

    Drupal resolves

    once per theme · cached · per snapshot
    • Picks the owning layer for each token: author, adapter, then defaults.
    • Follows {references} in the same mode: media-card.border → color.border.default → brand.navy.700.
    • Flattens primitives to plain values. brand.* never appears in the page.
    • Checks the value’s type (contrast rules come later).
    • Writes one block per mode. Each block holds only the values that differ from the base block.

    The browser resolves

    per element · live · no request to Drupal
    • Picks a block: OS dark or contrast setting, or [data-ds-mode] / [data-ds-contrast] on any element.
    • Sub-layer order fixes ties: dark beats more contrast. A dark + more-contrast value wins over both.
    • Resolves adapter values like var(--admin-color-border) against the theme’s own stylesheet, so the theme’s own dark mode keeps working.
    • The module’s CSS never changes: border-color: var(--ds-media-card-border).
    Ground rule: authors have every option, modules have one

    Who may point at what

    Who writes itLiteral #c8102ePrimitive brand.*Semantic color.*Component media-card.*Theme variable var(--admin-*)
    Module CSSNo (linter)NoYesIts ownNo
    Suggested mappingNoNoYesNoNo
    Theme adapterYesBrings its theme’s paletteYesYesYes, its main tool
    Author (registry)YesDeclares themYesYesPossible, rarely needed
    For the masses

    Say it without the jargon

    Modules stop choosing colors. They name what they need, such as “card edge”, and say which shared colour it should follow. The site decides the rest, for every theme and every mode, in one place.

    1. The module labels its parts. “Card edge: follow the house border colour.” The module ships no colour values, so it can’t clash with any theme.
    2. The site picks the colors. In one place, per theme. Change the house border colour once and every module that follows it changes. Change “card edge” and only the card changes.
    3. Drupal writes a colour chart for each theme. Every label gets its final colour for light, dark and high contrast, worked out ahead of time.
    4. The browser reads the chart. A dark sidebar on a light page reads the dark column. Nothing reloads and Drupal does no extra work.

    Why “stays a reference” is the important ask of core

    If core resolved {color.border.default} when it discovered the module’s token file, the card would be frozen to the default gray. Then when an author changed the house border colour to navy, the card would ignore it. A mapping must stay a pointer until Drupal resolves it per theme and mode. That is what “runtime reference” means here, and what the project asks of core in issue #3590100.