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, breakpointsdark.json— what's different in the dark themelight.json— what's different in the light theme
npm run generate-design-tokens feeds them through
style-dictionary twice, then syncs breakpoints:
_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:
// 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>);
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:
_variables.cssdefines every token on bare:root— and becauseconfig.jsonsourcesglobal.jsonplusdark.json, the:rootblock is the dark theme._variables.light.cssredefines only what differs, under[data-theme='light'].app/layout.tsxsetsdata-themeon<html>fromstores/themeStore.
Flip the attribute, and the cascade does the theming. Trace which value wins:
// 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>);
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:
// 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 />);
--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
- Edit the right file in
styles/tokens/: theme-independent →global.json; per-theme →dark.jsonandlight.json. - Regenerate:
npm run generate-design-tokens— rebuilds all four generated files and runssyncBreakpoints.js. - Commit the generated files alongside the JSON. They're checked in, so reviewers see the real effect in the diff.
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.