Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 13

Theming: eight themes from one pipeline

This chapter turns the single token build from Chapter 3 into a family of themes that change palette, spacing, radius, and typeface without touching a line of component code.

tokens/ 8 configs Default Default dark Roomy Roomy dark Soft Soft dark Compressed Compressed dark data-theme="roomy"
One pipeline emits eight themes. A data-theme attribute chooses which one applies.

Every component you have built reads its values from custom properties. Nothing hardcodes a color, a padding, or a font. That was the point of the three-tier token system, and this is the chapter where it pays for itself: you can now produce an entirely different-looking library by generating a second set of custom property values and letting the cascade do the rest.

No component changes, conditional logic, or theme context provider is involved.

What a theme actually is

A theme is one CSS file containing custom property declarations, scoped to an attribute selector:

[data-theme='dark'] {
  --rudi-color-text-default: var(--rudi-color-neutral-50);
  --rudi-color-background-surface: var(--rudi-color-neutral-900);
  /* …and so on */
}

Set data-theme="dark" on an ancestor element and every custom property inside that subtree resolves to the dark value. A component that reads var(--rudi-color-text-default) picks up the new value without knowing a theme system exists.

The base theme lives in :root, which means there is no data-theme="light". Light is what you get with no attribute at all. This trips people up when they build a theme switcher, so it is worth being explicit: to return to the default theme you remove the attribute rather than setting it to something.

// Switch to dark
document.documentElement.setAttribute('data-theme', 'dark')

// Back to default
document.documentElement.removeAttribute('data-theme')

Why an attribute rather than a media query

The obvious alternative is prefers-color-scheme, and for a dark mode alone it would be simpler. Two reasons not to.

A media query cannot express a user’s explicit choice. Someone whose system is in dark mode and who wants your documentation in light mode has no way to say so, and “respect the OS” stops being respectful the moment it overrides a preference the person stated.

More importantly, only two of the eight themes here are about light and dark. The rest change spacing density, corner radius, and typeface. prefers-color-scheme has nothing to say about whether a button should have 4px or 12px of vertical padding.

An attribute handles both, and you can still read prefers-color-scheme once at startup to pick the initial value.

The theme set

Three modifier themes, each in light and dark, plus the default pair:

Themedata-themeWhat changes
Default lightnoneBase: IBM Plex, neutral brand, standard spacing
Default darkdarkPalette inverts; everything else cascades from :root
Roomy lightroomyEnlarged spacing, rounded radii, warm neutrals, slate accent, Rubik
Roomy darkroomy-darkRoomy geometry and typeface over the dark palette
Soft lightsoftSoft warm palette, periwinkle accent, Nunito, large radii, pill buttons
Soft darksoft-darkSoft geometry and typeface over the dark palette
Compressed lightcompressedTight spacing, small radii, borders removed; palette and fonts unchanged
Compressed darkcompressed-darkCompressed geometry over the dark palette

Compressed is the clearest illustration of what token architecture buys you. It overrides the spacing and radius scales, then sets about twenty component.* border tokens to transparent to get a flat, borderless look. It never touches the palette or the typeface. That is enough to turn a comfortable marketing-site library into a dense data tool, from one JSON file and no component work.

One config per theme

Each theme is a separate Style Dictionary run. Create tokens/style-dictionary.config.dark.mjs:

// Primitive token groups that are theme-invariant and excluded from dark output
const PRIMITIVE_GROUPS = new Set(['blue', 'neutral', 'red', 'green', 'amber'])
const PRIMITIVE_CATEGORIES = new Set(['spacing', 'radius', 'font', 'motion'])

export default {
  source: [
    'tokens/tokens.json',
    'tokens/themes/dark/semantic.json',
    'tokens/components.json',
    'tokens/themes/dark/components.json',
  ],
  platforms: {
    css: {
      transformGroup: 'css',
      prefix: 'rudi',
      buildPath: 'tokens/build/',
      files: [
        {
          destination: 'tokens-dark.css',
          format: 'css/variables',
          options: {
            selector: '[data-theme="dark"]',
            outputReferences: true,
          },
          filter: (token) => {
            const category = token.path[0]
            if (PRIMITIVE_CATEGORIES.has(category)) return false
            if (category === 'color') {
              const group = token.path[1]
              if (group === 'dataviz') {
                return String(token.path[2]).startsWith('series')
              }
              return !PRIMITIVE_GROUPS.has(group)
            }
            return true
          },
        },
      ],
    },
  },
}

Two options do the work.

selector replaces :root in the output. Everything this config emits lands inside [data-theme="dark"] { … }.

source swaps the semantic layer and appends a component override. tokens/themes/dark/semantic.json replaces the base semantic file, redefining the same token paths against different primitives, and tokens/themes/dark/components.json adjusts the handful of component values that need more than a semantic swap. Tier 1 stays the same file, because the palette itself has not changed:

{
  "color": {
    "text": {
      "default": { "$value": "{color.neutral.50}" },
      "subtle": { "$value": "{color.neutral.400}" }
    },
    "background": {
      "surface-base": { "$value": "{color.neutral.900}" },
      "surface-raised": { "$value": "{color.neutral.800}" }
    }
  }
}

Light mode points text.default at neutral.900 and surface-base at neutral.0. Dark mode points them the other way. The neutral ramp is identical in both.

The filter is the important part

Without a filter, each theme file re-emits every token in the system. Eight themes then produce eight complete copies of the token set, which is both enormous and wrong: a theme that redeclares --rudi-spacing-4 inside [data-theme="dark"] has taken ownership of a value it never intended to change, so a consumer overriding spacing in :root can find their override ignored in dark mode (see the specificity trap below).

The filter answers one question per token: does this theme have an opinion about it?

The dark filter says no to spacing, radius, font, and motion, because dark mode is only about color. It says no to the primitive color ramps, because those hex values are identical in both themes. It says yes to semantic and component color tokens, which is the whole point, and it carves out one exception for dataviz.series-*, because chart series colors need to be brighter against a dark background. The dark theme sets those eight to literal hex values rather than aliasing the base ramp.

Compressed inverts the logic:

filter: (token) => {
  const category = token.path[0]
  if (category === 'color' || category === 'font' || category === 'motion') return false
  return true
}

Everything except color, typeface, and motion. That is broader than “spacing and radius”: it also lets through the component.* and typography.* tokens that resolve through spacing and radius, which is exactly how Compressed sets its border tokens to transparent without touching a color primitive.

Composing themes

compressed-dark needs the dark palette and the compressed geometry. There is no tokens/themes/compressed-dark/ directory, because it does not need one:

source: [
  'tokens/tokens.json',
  'tokens/themes/dark/semantic.json',
  'tokens/components.json',
  'tokens/themes/dark/components.json',
  'tokens/themes/compressed/tokens.json',
],

Two existing override sets, composed in the source array. Order matters and the base files stay in: tokens/components.json sits between the two dark files because the dark component overrides are a partial layer on top of it, not a replacement. Drop it and the build has no base component tier at all.

roomy-dark and soft-dark work the same way, each adding only a small semantic file for the accent color that differs between their light and dark forms.

This is why the source array is an explicit list rather than a glob. A glob would sweep in every theme’s overrides at once, and the failure would be silent.

Adding a theme

Five steps, and missing any one of them produces a theme that half-works:

  1. Create tokens/themes/<name>/ with the token files you are overriding.
  2. Create tokens/style-dictionary.config.<name>.mjs with the right selector, source, and filter.
  3. Add the config to the build:tokens chain in package.json.
  4. Add an @import for the generated file to both src/styles.css and src/app.css.
  5. Add a toolbar item to .storybook/preview.tsx.

Step four is the one people miss. Import into app.css only and the theme works perfectly in Storybook and does not exist for anyone who installs the package.

The build script chains every config:

{
  "scripts": {
    "build:tokens": "style-dictionary build --config tokens/style-dictionary.config.mjs && style-dictionary build --config tokens/style-dictionary.config.dark.mjs && style-dictionary build --config tokens/style-dictionary.config.roomy.mjs && style-dictionary build --config tokens/style-dictionary.config.roomy-dark.mjs && style-dictionary build --config tokens/style-dictionary.config.soft.mjs && style-dictionary build --config tokens/style-dictionary.config.soft-dark.mjs && style-dictionary build --config tokens/style-dictionary.config.compressed.mjs && style-dictionary build --config tokens/style-dictionary.config.compressed-dark.mjs"
  }
}

It is a long line. Chaining with && means a failure in any theme stops the build rather than leaving a stale file on disk.

Both CSS entry points import all eight, base first:

@import '../tokens/build/tokens.css';
@import '../tokens/build/tokens-dark.css';
@import '../tokens/build/tokens-roomy.css';
@import '../tokens/build/tokens-roomy-dark.css';
@import '../tokens/build/tokens-soft.css';
@import '../tokens/build/tokens-soft-dark.css';
@import '../tokens/build/tokens-compressed.css';
@import '../tokens/build/tokens-compressed-dark.css';

The specificity trap

:root and [data-theme="dark"] have the same specificity, (0,1,0). When data-theme sits on <html>, both match the same element and whichever stylesheet loads later wins. Theme files are imported after the base file, so theme values beat the base values. That is exactly what you want. When data-theme sits on a descendant instead, the theme rule wins inside that subtree regardless of order, because a value declared on an element beats one it inherits.

That makes a consumer override written the obvious way fragile:

/* Looks right. Breaks under the conditions below. */
:root {
  --rudi-color-brand-primary: #7c3aed;
}

If the consumer’s stylesheet loads before rudiment-ui/styles, or a theme is applied to one section of the page, [data-theme="dark"] sets --rudi-color-brand-primary itself and wins. The consumer’s brand color silently disappears when the user switches themes, and whether it happens depends on import order, which is exactly the kind of bug that appears in one app and not another.

The reliable fix is to repeat the override for every theme the consumer intends to use, and load it after the library CSS:

:root,
[data-theme='dark'],
[data-theme='roomy'],
[data-theme='roomy-dark'],
[data-theme='soft'],
[data-theme='soft-dark'],
[data-theme='compressed'],
[data-theme='compressed-dark'] {
  --rudi-color-brand-primary: #7c3aed;
}

Verbose, but it holds in every theme and wherever the attribute sits, as long as it loads after the library.

The tempting shortcut is to override a primitive instead, on the theory that themes redefine semantic tokens and leave the raw ramps alone. outputReferences: true from chapter 3 preserves the alias chain into the CSS, so overriding a link moves everything downstream, and for the dark and compressed themes that works.

It does not work in general. Roomy and Soft both ship their own color.neutral ramp, and their configs deliberately omit neutral from the excluded primitive groups, so each emits more than twenty --rudi-color-neutral-* declarations of its own. A consumer who overrides --rudi-color-neutral-900 in :root sees it apply in the default, dark, and both Compressed themes and vanish in the four Roomy and Soft themes.

Whichever you recommend, verify it against every theme you ship, and document it. Consumers will hit this.

Wiring the toolbar

Extend the globalTypes block from chapter 10 to all eight:

globalTypes: {
  theme: {
    description: 'Color theme',
    toolbar: {
      title: 'Theme',
      icon: 'paintbrush',
      items: [
        { value: 'light', title: 'Default Light', icon: 'sun' },
        { value: 'dark', title: 'Default Dark', icon: 'moon' },
        { value: 'roomy', title: 'Roomy Light', icon: 'sun' },
        { value: 'roomy-dark', title: 'Roomy Dark', icon: 'moon' },
        { value: 'soft', title: 'Soft Light', icon: 'sun' },
        { value: 'soft-dark', title: 'Soft Dark', icon: 'moon' },
        { value: 'compressed', title: 'Compressed Light', icon: 'sun' },
        { value: 'compressed-dark', title: 'Compressed Dark', icon: 'moon' },
      ],
      dynamicTitle: true,
    },
  },
},

The decorator written in chapter 10 already handles all of them; it sets whatever value it is given. Cycle a complex component through the eight and you will find the places where a hardcoded value slipped through, because it is the one thing that does not move.

Theming documentation — open in Storybook

A note on fonts

Roomy uses Rubik and Soft uses Nunito. Neither ships with the library, and neither loads automatically.

Bundling fonts in a component library is a bad trade. Font files are large, licensing varies, and consumers who use two of your eight themes would still pay for all of them. So the library declares a font family in a token and assumes the application provides the file.

The failure mode is quiet: an unloaded family falls through to the next entry in the stack, and the theme renders in the system sans-serif looking almost right. Document which families each theme expects, load them in .storybook/preview-head.html for development, and say so plainly in your README.

What you have now

Eight themes generated from one token source, switchable with a single attribute, with no component code involved in any of it.

More usefully, you now have the mechanism for a ninth. A consumer who wants their own brand theme writes a tokens/themes/<brand>/ directory and a config, and their theme is a peer of yours rather than a fork of your stylesheet.

The next chapter returns to components and fills in the set the guide has not covered yet.

This chapter has been corrected since it was first published: see what changed. Found an error? Report it.