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

Chapter 3

Design tokens from scratch

This chapter builds the token system that every component and layout primitive will reference.

color.neutral.900 global color.brand.primary semantic component.button.primary-bg component #171717 var(--rudi-component-button-primary-bg)
One value named at three tiers, then emitted once as a CSS custom property.

This chapter builds the token system that every component and layout primitive will reference. By the end you’ll have three token source files, a Style Dictionary pipeline that compiles them into namespaced CSS custom properties, and the two CSS entry points that decide what ships to consumers and what stays in development.

All three tiers from chapter 1 get built here, including the component tier.

Install Style Dictionary

npm install -D style-dictionary@4

This guide uses Style Dictionary v4. Version 5 exists and uses a compatible configuration format, but the examples here use v4.

Create the token source file

Create tokens/tokens.json. This is the single source of truth for every visual and layout value in the system.

The file uses the DTCG format. Each token has a $value (the actual value) and a $type (the kind of value). Tokens can reference other tokens using curly braces: {color.blue.500}. The DTCG format is the community standard for design token interchange. These files use the draft syntax Style Dictionary v4 reads, with plain strings for colors and dimensions. The stable 2025.10 specification writes color, dimension, and duration values as objects instead, so expect that to change when you move to a tool that targets it.

Start with the global tokens (tier 1):

{
  "color": {
    "blue": {
      "50": { "$value": "#EFF6FF", "$type": "color" },
      "100": { "$value": "#DBEAFE", "$type": "color" },
      "200": { "$value": "#BFDBFE", "$type": "color" },
      "300": { "$value": "#93C5FD", "$type": "color" },
      "400": { "$value": "#60A5FA", "$type": "color" },
      "500": { "$value": "#3B82F6", "$type": "color" },
      "600": { "$value": "#2563EB", "$type": "color" },
      "700": { "$value": "#1D4ED8", "$type": "color" },
      "800": { "$value": "#1E40AF", "$type": "color" },
      "900": { "$value": "#1E3A8A", "$type": "color" }
    },
    "neutral": {
      "0": { "$value": "#FFFFFF", "$type": "color" },
      "50": { "$value": "#FAFAFA", "$type": "color" },
      "100": { "$value": "#F5F5F5", "$type": "color" },
      "200": { "$value": "#E5E5E5", "$type": "color" },
      "300": { "$value": "#D4D4D4", "$type": "color" },
      "400": { "$value": "#A3A3A3", "$type": "color" },
      "500": { "$value": "#737373", "$type": "color" },
      "600": { "$value": "#525252", "$type": "color" },
      "700": { "$value": "#404040", "$type": "color" },
      "800": { "$value": "#262626", "$type": "color" },
      "900": { "$value": "#171717", "$type": "color" }
    },
    "red": {
      "50": { "$value": "#FEF2F2", "$type": "color" },
      "100": { "$value": "#FEE2E2", "$type": "color" },
      "500": { "$value": "#EF4444", "$type": "color" },
      "600": { "$value": "#DC2626", "$type": "color" },
      "700": { "$value": "#B91C1C", "$type": "color" },
      "800": { "$value": "#CB0A04", "$type": "color" }
    },
    "green": {
      "50": { "$value": "#F0FDF4", "$type": "color" },
      "100": { "$value": "#DCFCE7", "$type": "color" },
      "500": { "$value": "#22C55E", "$type": "color" },
      "600": { "$value": "#16A34A", "$type": "color" },
      "700": { "$value": "#15803D", "$type": "color" }
    },
    "amber": {
      "50": { "$value": "#FFFBEB", "$type": "color" },
      "100": { "$value": "#FEF3C7", "$type": "color" },
      "500": { "$value": "#F59E0B", "$type": "color" },
      "700": { "$value": "#B45309", "$type": "color" }
    },
    "dataviz": {
      "1": { "$value": "#2563EB", "$type": "color" },
      "2": { "$value": "#D946EF", "$type": "color" },
      "3": { "$value": "#F59E0B", "$type": "color" },
      "4": { "$value": "#10B981", "$type": "color" },
      "5": { "$value": "#EF4444", "$type": "color" },
      "6": { "$value": "#8B5CF6", "$type": "color" },
      "7": { "$value": "#06B6D4", "$type": "color" },
      "8": { "$value": "#F97316", "$type": "color" }
    }
  },
  "spacing": {
    "0": { "$value": "0", "$type": "dimension" },
    "1": { "$value": "0.25rem", "$type": "dimension" },
    "2": { "$value": "0.5rem", "$type": "dimension" },
    "3": { "$value": "0.75rem", "$type": "dimension" },
    "4": { "$value": "1rem", "$type": "dimension" },
    "6": { "$value": "1.5rem", "$type": "dimension" },
    "8": { "$value": "2rem", "$type": "dimension" },
    "12": { "$value": "3rem", "$type": "dimension" }
  },
  "radius": {
    "none": { "$value": "0", "$type": "dimension" },
    "sm": { "$value": "0.125rem", "$type": "dimension" },
    "md": { "$value": "0.375rem", "$type": "dimension" },
    "lg": { "$value": "0.5rem", "$type": "dimension" },
    "full": { "$value": "9999px", "$type": "dimension" }
  },
  "font": {
    "family": {
      "sans": {
        "$value": "'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif",
        "$type": "fontFamily"
      },
      "mono": {
        "$value": "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
        "$type": "fontFamily"
      }
    },
    "size": {
      "xs": { "$value": "0.75rem", "$type": "dimension" },
      "sm": { "$value": "0.875rem", "$type": "dimension" },
      "base": { "$value": "1rem", "$type": "dimension" },
      "lg": { "$value": "1.125rem", "$type": "dimension" },
      "xl": { "$value": "1.25rem", "$type": "dimension" },
      "2xl": { "$value": "1.5rem", "$type": "dimension" },
      "3xl": { "$value": "1.875rem", "$type": "dimension" },
      "4xl": { "$value": "2.25rem", "$type": "dimension" }
    },
    "weight": {
      "regular": { "$value": "400", "$type": "fontWeight" },
      "medium": { "$value": "500", "$type": "fontWeight" },
      "semibold": { "$value": "600", "$type": "fontWeight" },
      "bold": { "$value": "700", "$type": "fontWeight" }
    },
    "lineHeight": {
      "tight": { "$value": "1.25", "$type": "number" },
      "normal": { "$value": "1.5", "$type": "number" },
      "relaxed": { "$value": "1.75", "$type": "number" }
    }
  },
  "motion": {
    "duration": {
      "fast": { "$value": "100ms", "$type": "duration" },
      "base": { "$value": "150ms", "$type": "duration" },
      "slow": { "$value": "300ms", "$type": "duration" },
      "spinner": { "$value": "600ms", "$type": "duration" }
    },
    "easing": {
      "standard": { "$value": "cubic-bezier(0.4, 0, 0.2, 1)", "$type": "cubicBezier" }
    }
  }
}

The dataviz ramp exists so charts can assign a distinct, accessible color per series without reaching into the brand palette. The motion namespace does the same job for animation: every transition and spinner in the library reads a duration and easing token instead of hardcoding milliseconds, which means a theme can slow the whole system down or flatten it to near-instant in one place.

These are placeholder values. The structure matters more than the specific hex codes at this stage. Whoever adopts the library swaps these values to match their brand; the structure stays the same.

Add semantic tokens

Create a separate file for the semantic tokens at tokens/semantic.json. Style Dictionary merges all files listed in its source array, so keeping global and semantic tokens in separate files avoids an invalid JSON structure (two root objects in one file won’t parse). The semantic tokens reference the global tokens by path:

{
  "color": {
    "brand": {
      "primary": { "$value": "{color.neutral.900}" },
      "primary-hover": { "$value": "{color.neutral.700}" },
      "primary-active": { "$value": "{color.neutral.800}" }
    },
    "text": {
      "default": { "$value": "{color.neutral.900}" },
      "subtle": { "$value": "{color.neutral.600}" },
      "disabled": { "$value": "{color.neutral.400}" },
      "on-brand": { "$value": "{color.neutral.0}" },
      "on-inverted": { "$value": "{color.neutral.0}" }
    },
    "background": {
      "surface-base": { "$value": "{color.neutral.0}" },
      "surface": { "$value": "{color.background.surface-base}" },
      "surface-raised": { "$value": "{color.neutral.50}" },
      "surface-sunken": { "$value": "{color.neutral.100}" },
      "surface-overlay": { "$value": "rgb(0 0 0 / 0.5)" },
      "disabled": { "$value": "{color.neutral.100}" },
      "overlay": { "$value": "{color.background.surface-overlay}" },
      "inverted": { "$value": "{color.neutral.900}" }
    },
    "border": {
      "default": { "$value": "{color.neutral.200}" },
      "focus": { "$value": "{color.blue.500}" },
      "error": { "$value": "{color.red.500}" }
    },
    "feedback": {
      "error": { "$value": "{color.red.500}" },
      "error-surface": { "$value": "{color.red.50}" },
      "error-border": { "$value": "{color.red.100}" },
      "error-text": { "$value": "{color.red.700}" },
      "success": { "$value": "{color.green.500}" },
      "success-surface": { "$value": "{color.green.50}" },
      "success-border": { "$value": "{color.green.100}" },
      "success-text": { "$value": "{color.green.700}" },
      "warning": { "$value": "{color.amber.500}" },
      "warning-surface": { "$value": "{color.amber.50}" },
      "warning-border": { "$value": "{color.amber.100}" },
      "warning-text": { "$value": "{color.amber.700}" },
      "info-surface": { "$value": "{color.blue.50}" },
      "info-border": { "$value": "{color.blue.100}" },
      "info-text": { "$value": "{color.blue.700}" }
    },
    "dataviz": {
      "series-1": { "$value": "{color.dataviz.1}" },
      "series-2": { "$value": "{color.dataviz.2}" },
      "series-3": { "$value": "{color.dataviz.3}" },
      "series-4": { "$value": "{color.dataviz.4}" },
      "series-5": { "$value": "{color.dataviz.5}" },
      "series-6": { "$value": "{color.dataviz.6}" },
      "series-7": { "$value": "{color.dataviz.7}" },
      "series-8": { "$value": "{color.dataviz.8}" }
    }
  }
}

Three things in that file are worth pausing on.

The brand color is a neutral, not a blue. That surprises people. A starter library that ships loud blue buttons forces every adopter to change them before shipping anything. A near-black primary looks deliberate on day one and gets replaced when the adopter has an actual brand. The palette still carries a full blue ramp for focus rings and informational states.

Error, success, and warning each come as a set of four. Info has the last three only. error is the saturated stroke color, error-surface is the tinted background an alert sits on, error-border is the hairline around it, and error-text is the darker value that stays legible on that tint. One value per state is enough for a border and nothing else.

Some semantic tokens alias other semantic tokens. background.surface points at background.surface-base, and background.overlay points at background.surface-overlay. The indirection lets a theme retarget the whole surface family at its base while leaving every component reference intact.

Note that this file contains only a color object. Spacing, radius, and typography values do not get a semantic tier. They go straight from tier 1 to the component tier.

Add component tokens

Chapter 1 argued that the component tier cannot be deferred. Here is why, concretely, before you write a component.

A button needs a horizontal padding at three sizes, a vertical padding at three sizes, a font size at three sizes, a radius, a gap between icon and label, a focus outline color, and a background, text, hover, and active color for each of four variants. None of those are semantic decisions about the system. They are decisions about a button. Putting them in the semantic tier means every future component competes for names in the same namespace, and changing a button’s radius means changing a value that something else may also read.

Create tokens/components.json. It holds three namespaces: component for per-component values, layout for the layout primitives you build in chapters 4 and 5, and typography for the type scale you build in chapter 6.

Here is the shape, using the button as the example:

{
  "component": {
    "button": {
      "gap": { "$value": "{spacing.2}" },
      "font-weight": { "$value": "{font.weight.medium}" },
      "radius": { "$value": "{radius.sm}" },
      "focus-outline-color": { "$value": "{color.border.focus}" },
      "padding-y-sm": { "$value": "{spacing.1}" },
      "padding-x-sm": { "$value": "{spacing.3}" },
      "padding-y-md": { "$value": "{spacing.2}" },
      "padding-x-md": { "$value": "{spacing.4}" },
      "font-size-sm": { "$value": "{font.size.sm}" },
      "font-size-md": { "$value": "{font.size.base}" },
      "primary-bg": { "$value": "{color.brand.primary}" },
      "primary-text": { "$value": "{color.text.on-brand}" },
      "primary-hover-bg": { "$value": "{color.brand.primary-hover}" }
    }
  },
  "layout": {
    "stack": {
      "space": {
        "default": { "$value": "{spacing.6}" },
        "dense": { "$value": "{spacing.3}" },
        "loose": { "$value": "{spacing.12}" }
      }
    },
    "sidebar": {
      "width": { "$value": "20rem" },
      "content-min": { "$value": "50%" },
      "space": { "$value": "{layout.stack.space.default}" }
    },
    "grid": {
      "min-cell": { "$value": "15rem" },
      "space": { "$value": "{layout.stack.space.default}" }
    },
    "center": {
      "max-width": { "$value": "60rem" },
      "gutters": { "$value": "{spacing.4}" }
    },
    "cover": {
      "min-height": { "$value": "100vh" },
      "space": { "$value": "{layout.stack.space.default}" }
    },
    "switcher": {
      "threshold": { "$value": "30rem" },
      "space": { "$value": "{layout.stack.space.default}" }
    },
    "cluster": {
      "space": { "$value": "{spacing.4}" }
    },
    "box": {
      "padding": { "$value": "{spacing.4}" },
      "border-color": { "$value": "{color.border.default}" },
      "border-radius": { "$value": "{radius.md}" },
      "invert-bg": { "$value": "{color.background.inverted}" },
      "invert-text": { "$value": "{color.text.on-inverted}" }
    }
  }
}

Look at the space entries under sidebar, grid, cover, and switcher. Each one aliases layout.stack.space.default rather than reading it directly. Out of the box every primitive shares the same rhythm, which is what you want. But a theme that wants roomier sidebars without touching global vertical rhythm has a single value to change. Pointing all four primitives at the Stack token directly would take that option away.

The same reasoning applies to box.border-color. It aliases color.border.default, so Box borders match every other border by default and can be retargeted alone.

Chapter 6 adds the typography namespace to this file. Chapters 7 through 9 add the remaining component entries as each component is built. By the end the file carries entries for 36 components.

Configure Style Dictionary

Create tokens/style-dictionary.config.mjs:

export default {
  source: [
    'tokens/tokens.json',
    'tokens/semantic.json',
    'tokens/components.json',
  ],
  platforms: {
    css: {
      transformGroup: 'css',
      prefix: 'rudi',
      buildPath: 'tokens/build/',
      files: [
        {
          destination: 'tokens.css',
          format: 'css/variables',
          options: {
            outputReferences: true,
          },
        },
      ],
    },
  },
}

The .mjs extension makes the ES module syntax work regardless of what the nearest package.json says about module type.

The prefix: 'rudi' option puts a --rudi- namespace on every generated property. Consumers drop this library into applications that already have their own custom properties, and --color-text-default is a name somebody else has probably used. --rudi-color-text-default is not.

The outputReferences: true option preserves the alias relationships in the output. Instead of resolving color.brand.primary to its final hex value, you get:

--rudi-color-brand-primary: var(--rudi-color-neutral-900);

This matters for debugging. When you inspect an element in browser dev tools, you can trace a value back through its aliases to understand why it resolves the way it does. It also matters for theming: a theme that overrides --rudi-color-neutral-900 moves the brand color with it, without having to know that the brand token exists. That holds when the theme is applied to the root element. Inside a subtree, --rudi-color-brand-primary has already been resolved at :root and inherits that value, which is why the theme files in chapter 13 redeclare semantic tokens.

Add a build script to package.json, and set "type": "module" at the root level:

{
  "type": "module",
  "scripts": {
    "build:tokens": "style-dictionary build --config tokens/style-dictionary.config.mjs"
  }
}

Run it:

npm run build:tokens

This generates tokens/build/tokens.css, containing every token as a CSS custom property under :root. Chapter 13 adds seven more configurations to this script, one per theme, each writing its own file.

Wire tokens into your CSS

The library needs two CSS entry points, and the difference between them is the most consequential decision in this chapter.

Create src/styles.css. This is what the published package ships:

/**
 * Library CSS entry — imported by src/index.ts so the published build ships
 * token definitions alongside the per-component stylesheets.
 *
 * Deliberately does NOT import Tailwind. Components use plain class names and
 * custom properties only, so pulling in Tailwind would ship a preflight reset
 * that clobbers consumer styles for no benefit.
 */
@import '../tokens/build/tokens.css';

Then create src/app.css. This is the development and documentation entry point, loaded by Storybook in chapter 10:

@import 'tailwindcss';
@import '../tokens/build/tokens.css';

Chapter 2 installed Tailwind, and it earns its place here: the Storybook documentation pages and the example compositions are ordinary application code, and writing them with utilities is faster than writing a stylesheet for each one. What Tailwind does not do is style the components themselves.

That is a deliberate constraint, and it costs something. You give up authoring components as utility strings, which many React developers find quicker. What you get back is a package that cannot break the application that installs it. Tailwind’s preflight is a global reset; a component library that ships one silently restyles the consumer’s headings, lists, and form controls. Consumers who use Tailwind already have their own preflight, and consumers who do not never asked for one.

So components read tokens directly:

.rudi-button--primary {
  background-color: var(--rudi-component-button-primary-bg);
  color: var(--rudi-component-button-primary-text);
}

No @theme block, no utility class generation, no build step between the token and the style. The chain is three hops and every hop is inspectable in dev tools: --rudi-component-button-primary-bg resolves to --rudi-color-brand-primary, which resolves to --rudi-color-neutral-900, which resolves to #171717.

Verify the pipeline

Confirm the pipeline works end to end:

  1. Change color.neutral.900 in tokens/tokens.json to a different hex value.
  2. Run npm run build:tokens.
  3. Open tokens/build/tokens.css and confirm --rudi-color-neutral-900 reflects the new value.
  4. Confirm that --rudi-color-brand-primary still reads var(--rudi-color-neutral-900) rather than a resolved hex. That alias is what makes one edit propagate.

Edit a token, run the build, and every custom property that references it updates.

What you have now

  • tokens/tokens.json with the raw palette, spacing, radius, type, and motion values
  • tokens/semantic.json with color meanings, including the four-value feedback sets
  • tokens/components.json with the component and layout namespaces
  • A Style Dictionary config that merges all three into tokens/build/tokens.css under a --rudi- prefix, with aliases preserved
  • Two CSS entry points: src/styles.css for the package, src/app.css for Storybook

The next chapter uses these tokens to build your first layout primitive.

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