Skip to main content

Design Tokens

Here's a small experiment you can run on the codebase: search components/ for the hex value of the brand yellow. You'll barely find it. Buttons are yellow, badges are yellow, and virtually none of them contain a color.

That's by design — literally. Every color, font size, spacing value, and corner radius in this app is a design token: a named design decision stored in JSON, compiled into the forms each part of the stack consumes. Change the value once, regenerate, and every component follows. No component knows what color it is; it only knows the name of the decision it defers to.

This lesson walks the pipeline end to end, then hands you the sandboxes to poke it.

From one JSON entry to four files

The source of truth is three files in styles/tokens/:

  • global.json — theme-invariant decisions: brand colors, spacing, typography, radii, breakpoints
  • dark.json — what's different in the dark theme
  • light.json — what's different in the light theme

npm run generate-design-tokens feeds them through style-dictionary twice, then syncs breakpoints:

Four generated files, zero hand edits

_variables.css, _variables.light.css, variables.js, and variables.light.js all begin with "Do not edit directly." Edit the JSON and regenerate. A hand edit survives exactly until the next person runs the build — then it vanishes without a trace.

What a token's name becomes

style-dictionary flattens the JSON path and transforms it per platform: kebab-case custom properties for CSS, PascalCase named exports for JS. Both come from the same path, so a rename in JSON renames both. Try your own paths:

Path transform — what your JSON key becomes
// Reproduces the style-dictionary 'css' and 'js' transform groups
// as configured in config.json / config.light.json.

function splitSegment(segment) {
// camelCase and digit runs both become their own words
return segment
  .replace(/([a-z])([A-Z])/g, '$1 $2')
  .replace(/([a-zA-Z])([0-9])/g, '$1 $2')
  .split(' ');
}

function toCssVariable(path) {
const words = [];
path.forEach(function (segment) {
  splitSegment(segment).forEach(function (w) { words.push(w.toLowerCase()); });
});
return '--' + words.join('-');
}

function toJsExport(path) {
const words = [];
path.forEach(function (segment) {
  splitSegment(segment).forEach(function (w) {
    words.push(w.charAt(0).toUpperCase() + w.slice(1));
  });
});
return words.join('');
}

// --- Real paths from styles/tokens/ — add your own ------------------------

const paths = [
['color', 'primary', 'base'],
['color', 'text', 'primary', 'normal'],
['color', 'neutral', '700'],
['typography', 'font-size', 'xs'],
['cornerRounding', 'extraSoft'],
['spacing', '250'],
];

render(<pre style={{margin: 0}}>{paths.map(function (p) {
return p.join('.').padEnd(30) +
  'css: ' + toCssVariable(p).padEnd(30) +
  'js: ' + toJsExport(p);
}).join('\n')}</pre>);
Output

And the three ways components consume the result, in order of preference:

/* 1. CSS custom property — the default, in a component's .module.scss */
.tile {
background-color: var(--color-background-paper);
color: var(--color-text-primary-normal);
}
// 2. JS constant — when a value must cross into JavaScript
// (an ArcGIS symbol color, an inline style, a library prop)
import { ColorPrimaryBase, SpacingBase } from '@/styles/variables';
// 3. The MUI theme — for MUI primitives only; styles/theme.ts is fed
// from the same generated tokens, so all three stay in step
<Button sx={{ backgroundColor: 'primary.main' }} />

How theming works (there is no theme engine)

Ready for the trick? There's no runtime theme resolver at all. It's pure CSS cascade:

  1. _variables.css defines every token on bare :root — and because config.json sources global.json plus dark.json, the :root block is the dark theme.
  2. _variables.light.css redefines only what differs, under [data-theme='light'].
  3. app/layout.tsx sets data-theme on <html> from stores/themeStore.

Flip the attribute, and the cascade does the theming. Trace which value wins:

Effective token value per theme
// Mirrors the cascade: :root (global + dark) with [data-theme='light'] overriding.

const globalTokens = {
'--color-primary-base': '#3498db',
'--color-button-primary-base': '#ffcd00',
'--spacing-200': '8',
};

const darkTokens = {
'--color-text-primary-normal': 'rgba(255, 255, 255, 1)',
'--color-background-default': '#000000',
'--color-action-active': 'rgba(144, 202, 249, 1)',
};

const lightTokens = {
'--color-text-primary-normal': 'rgba(0, 0, 0, 0.87)',
'--color-background-default': '#f0ebe1',
'--color-action-active': 'rgba(25, 118, 210, 1)',
};

function resolve(name, theme) {
const root = Object.assign({}, globalTokens, darkTokens);
if (theme === 'light' && Object.prototype.hasOwnProperty.call(lightTokens, name)) {
  return lightTokens[name];
}
return Object.prototype.hasOwnProperty.call(root, name) ? root[name] : '(undefined!)';
}

const names = [
'--color-text-primary-normal',
'--color-action-active',
'--color-primary-base',      // global only: identical in both themes
];

render(<pre style={{margin: 0}}>{names.map(function (n) {
return n.padEnd(30) + 'dark: ' + resolve(n, 'dark').padEnd(24) + 'light: ' + resolve(n, 'light');
}).join('\n')}</pre>);
Output
Adding a themed color

A color that differs per theme needs an entry in both dark.json and light.json. Put it in global.json only when it's genuinely theme-independent. And never in light.json alone — dark mode would get an undefined custom property, and every declaration using it would be silently dropped.

The spacing scale — and its sharp edge

Spacing is an 8px-base scale. See it to scale:

Spacing scale, to scale
// Values are the real contents of styles/tokens/global.json.
function SpacingScale() {
const spacings = [
  { name: '--spacing-50', value: 2 },
  { name: '--spacing-100', value: 4 },
  { name: '--spacing-200', value: 8 },
  { name: '--spacing-250', value: 12 },
  { name: '--spacing-300', value: 16 },
  { name: '--spacing-400', value: 24 },
  { name: '--spacing-500', value: 32 },
];
return (
  <div>
    {spacings.map(function (s) {
      return (
        <div key={s.name} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
          <code style={{ width: 140, fontSize: 12 }}>{s.name}</code>
          <span style={{ width: 30, fontSize: 12 }}>{s.value}</span>
          <div style={{ height: 10, width: s.value * 8, background: 'currentColor', opacity: 0.6 }} />
        </div>
      );
    })}
  </div>
);
}
render(<SpacingScale />);
Gotcha: spacing tokens are unitless

--spacing-200 is 8, not 8px. That's exactly right for JavaScript (SpacingBase * 2) and for MUI's spacing factor — and invalid as a CSS length: padding: var(--spacing-200) is a dropped declaration, no padding, no error, nothing in the console. There are live examples of this in styles/global.scss today. In CSS, use calc(var(--spacing-200) * 1px) or a token that carries a unit (the --typography-font-size-* tokens do).

Changing a token, step by step

  1. Edit the right file in styles/tokens/: theme-independent → global.json; per-theme → dark.json and light.json.
  2. Regenerate: npm run generate-design-tokens — rebuilds all four generated files and runs syncBreakpoints.js.
  3. Commit the generated files alongside the JSON. They're checked in, so reviewers see the real effect in the diff.
Breakpoints are not just a token

A breakpoint must reach two places at once: the SCSS mixins in styles/_breakpoints.scss and theme.breakpoints.values in styles/theme.ts. syncBreakpoints.js keeps them in step — which is why the token command runs it. Change one by hand without the other and you get layouts where the SCSS media query and the MUI breakpoint disagree, a bug class that only appears in a narrow band of viewport widths.

Wrapping up

Design decisions live in three JSON files; a generator compiles them into CSS custom properties, JS constants, and the MUI theme; and theming is nothing but a data-theme attribute flip riding the CSS cascade. Respect the two sharp edges — generated files are never hand-edited, and spacing tokens carry no unit — and the system stays boring, in the best way.

Further down the docs: how we measure what operators actually do.