Skip to content

Design Tokens

This lesson explains design tokens for Web Components, how to define shared color, spacing, and typography values with CSS custom properties, and how to theme encapsulated components across applications.

What Are Design Tokens?

Design tokens are named design decisions such as colors, spacing, font sizes, border radii, and shadows. Instead of hardcoding #2563eb or 16px throughout components, you store values once and reference them everywhere.

In Web Components, tokens are usually implemented as CSS custom properties. They work well with Shadow DOM because custom properties can pierce shadow boundaries, making them ideal for shared theming.

Token Type Example
Color --color-primary
Spacing --space-md
Typography --font-size-body
Radius --radius-sm
Shadow --shadow-card
Motion --duration-fast

Basic Token Example

:root {
  --color-primary: #2563eb;
  --color-surface: #ffffff;
  --space-sm: 0.5rem;
  --space-md: 1rem;
  --radius-md: 0.5rem;
}
class AppCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>
        article {
          background: var(--color-surface, #fff);
          padding: var(--space-md, 1rem);
          border-radius: var(--radius-md, 0.5rem);
        }
      </style>
      <article><slot></slot></article>
    `;
  }
}

Global tokens on :root flow into shadow styles through var(). Fallback values keep components usable even when a host app does not define every token.

Primitive vs Semantic Tokens

Layer Purpose Example
Primitive Raw design values --blue-600
Semantic Meaning in UI context --color-primary
Component Component-specific alias --app-button-bg
:root {
  --blue-600: #2563eb;
  --color-primary: var(--blue-600);
}

app-button {
  --app-button-bg: var(--color-primary);
}

Primitive tokens capture the palette. Semantic tokens express intent. Component tokens expose a small override surface for consumers.

Component-Level Token API

:host {
  --app-button-bg: var(--color-primary, #2563eb);
  --app-button-color: var(--color-on-primary, #ffffff);
  --app-button-radius: var(--radius-sm, 0.375rem);
}

button {
  background: var(--app-button-bg);
  color: var(--app-button-color);
  border-radius: var(--app-button-radius);
}
<app-button style="--app-button-bg: #059669;">
  Confirm
</app-button>

Define public tokens on :host so apps can theme a component without breaking Shadow DOM encapsulation.

Shared Token File Structure

design-system/
├── tokens/
│   ├── colors.css
│   ├── spacing.css
│   ├── typography.css
│   └── index.css
└── components/
    └── app-button.js
/* tokens/colors.css */
:root {
  --gray-900: #111827;
  --blue-600: #2563eb;
  --color-text: var(--gray-900);
  --color-primary: var(--blue-600);
}
/* tokens/index.css */
@import "./colors.css";
@import "./spacing.css";
@import "./typography.css";

Centralize tokens in dedicated CSS files and import them once in the app or design system entry point.

Theming with Design Tokens

:root {
  --color-surface: #ffffff;
  --color-text: #111827;
}

:root[data-theme="dark"] {
  --color-surface: #111827;
  --color-text: #f9fafb;
}

Because components read semantic tokens, switching themes means updating token values on :root or a theme container, not rewriting component CSS.

Design Tokens in Lit

import { LitElement, css, html } from "lit";

class AppBadge extends LitElement {
  static styles = css`
    :host {
      --badge-bg: var(--color-primary, #2563eb);
      --badge-color: var(--color-on-primary, #fff);
    }

    span {
      background: var(--badge-bg);
      color: var(--badge-color);
      padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
      border-radius: var(--radius-full, 999px);
    }
  `;

  render() {
    return html`<span><slot></slot></span>`;
  }
}

Lit components use the same token pattern in static styles. Global tokens still cascade into shadow styles through var().

Tokens with Adopted Stylesheets

const tokenSheet = new CSSStyleSheet();
tokenSheet.replaceSync(`
  :host {
    --app-card-padding: var(--space-md, 1rem);
  }
`);

const componentSheet = new CSSStyleSheet();
componentSheet.replaceSync(`
  article {
    padding: var(--app-card-padding);
  }
`);

this.shadowRoot.adoptedStyleSheets = [
  tokenSheet,
  componentSheet
];

Shared constructable stylesheets can define token aliases once and be adopted by many component instances. See Adopted Stylesheets for more detail.

Fallback Values in var()

button {
  background: var(--app-button-bg, var(--color-primary, #2563eb));
  padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
}

Use layered fallbacks so components remain usable in isolation, inside a partially themed app, or in Storybook-style demos without full global token setup.

Spacing and Typography Scales

:root {
  --space-xs: 0.25rem;
  --space-sm: 0.5rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2rem;

  --font-size-sm: 0.875rem;
  --font-size-md: 1rem;
  --font-size-lg: 1.25rem;
  --font-family-sans: system-ui, sans-serif;
}

Consistent scales keep components visually aligned across a design system. Prefer scale tokens over arbitrary pixel values inside shadow styles.

Tokens vs Hardcoded Styles

Approach Pros Cons
Design tokens Consistent theming, easier updates Requires naming discipline
Hardcoded values Fast for prototypes Hard to theme and maintain
::part() styling Fine-grained external control More consumer complexity

Full Token-Based Button Component

class AppButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          --btn-bg: var(--color-primary, #2563eb);
          --btn-color: var(--color-on-primary, #fff);
          --btn-radius: var(--radius-sm, 0.375rem);
          --btn-padding-y: var(--space-xs, 0.375rem);
          --btn-padding-x: var(--space-md, 1rem);
        }

        button {
          background: var(--btn-bg);
          color: var(--btn-color);
          border: 0;
          border-radius: var(--btn-radius);
          padding: var(--btn-padding-y) var(--btn-padding-x);
          font: inherit;
          cursor: pointer;
        }
      </style>
      <button type="button"><slot></slot></button>
    `;
  }
}

When to Use Design Tokens

  • You build a shared Web Components design system.
  • Multiple apps need consistent branding and theming.
  • You support light, dark, or white-label themes.
  • Components use Shadow DOM and need external theme control.
  • Design and engineering share a common visual language.
  • You want to change spacing or colors globally with one update.

Design Token Best Practices

  • Use semantic names such as --color-primary, not --blue in components.
  • Expose a small public token API on :host.
  • Always provide sensible var() fallbacks.
  • Keep primitive and semantic token layers separate.
  • Document which tokens consumers can override.
  • Prefer tokens over ::part() for broad theming.
  • Version and publish token CSS with the component library.

Common Design Token Mistakes

  • Hardcoding colors inside shadow styles after adopting tokens.
  • Using inconsistent token naming across components.
  • Exposing too many component tokens with no clear purpose.
  • Skipping fallbacks and breaking isolated component demos.
  • Mixing primitive color names into component CSS directly.
  • Expecting global classes to style shadow internals.
  • Changing token meaning without versioning the design system.

Key Takeaways

  • Design tokens store reusable visual decisions by name.
  • CSS custom properties are the standard token mechanism in Web Components.
  • Tokens pierce Shadow DOM and enable encapsulated theming.
  • Primitive, semantic, and component token layers keep systems maintainable.
  • Fallback values make components resilient in any host app.
  • Good tokens make design systems easier to scale across teams.

Pro Tip

Publish a small tokens.css file with your component library. Apps import it once on :root, and every shadow component immediately inherits the same visual foundation.