Dark mode support almost always starts in good shape: a clean set of tokens, --bg, --text, --accent, defined once and redefined under a dark theme. Then a new component ships three weeks later with a hardcoded color: #333 because that’s what looked right in whatever theme the person building it happened to be looking at, and nobody notices until a user reports invisible text. Six months in, half the app quietly breaks in one theme or the other. This isn’t a discipline problem, discipline doesn’t scale across a team and a calendar. It’s a systems problem, and the fix looks more like a linter than a design decision.

Name tokens by role, not by value

The mistake that causes the most damage later is naming a token after what it currently looks like instead of what it’s for: --gray-600 instead of --text-secondary. That’s fine right up until dark mode needs gray-600 to mean something visually different than it means in light mode, and now the token name is actively lying about what it represents.

/* Named by value: breaks the moment dark mode needs a different actual color */
:root { --gray-600: #4b5563; }
.card-subtitle { color: var(--gray-600); }

/* Named by role: the role stays constant, the value underneath can differ per theme */
:root { --text-secondary: #4b5563; }
:root[data-theme="dark"] { --text-secondary: #a6acb3; }
.card-subtitle { color: var(--text-secondary); }

Role-based names also make token systems self-documenting: a new component author reaching for a color can pick --text-secondary or --border-strong without needing to know the actual hex values for either theme, which is the whole point.

Make the wrong thing hard to do, not just discouraged

Telling people not to hardcode colors doesn’t survive contact with a deadline. What does survive: a CI check that fails a PR containing a raw hex value or an rgb()/rgba() literal outside the token definition file itself. A simple grep-based check catches most of it:

# in CI: fail if any raw hex color shows up outside the tokens file
if grep -rEn '#[0-9a-fA-F]{3,6}\b' \
    --include='*.css' \
    --exclude='tokens.css' \
    src/; then
  echo "Raw color value found outside tokens.css — use a token instead."
  exit 1
fi

This is a blunt tool and it’s fine for it to be blunt. The goal isn’t elegance, it’s making the easy path (copy an existing pattern, reach for a token) also the correct path, so correctness doesn’t depend on everyone remembering a rule from the design system docs.

State colors are the ones everyone forgets

Base text and background get tokenized early because they’re the most visible. What gets missed: disabled control colors, placeholder text, a focus ring against a colored surface, error and success states. These regularly get built with an opacity trick, color: rgba(0,0,0,0.4) for a disabled state, that behaves completely differently against a dark background than a light one, because opacity composites against whatever’s underneath rather than producing a fixed, checkable color. Give these their own explicit tokens (--text-disabled, --border-focus, --danger) defined per theme, the same as base colors, instead of derived on the fly with transparency.

Check contrast per theme, not once

A pairing that passes contrast in light mode can fail in dark mode with the exact same relative values, because contrast ratio is a function of both colors together, not either one alone. Every token pairing that matters for readability, text on background, an icon on a button, placeholder on an input, needs checking in both themes independently. This is tedious to do by hand across a whole token set, which is exactly why it needs to happen once at the token level rather than per-component: fix it in the eight or so base tokens, and every component built from them inherits the fix automatically.

The actual takeaway

A token system doesn’t fail because supporting two themes is inherently hard. It fails because there’s nothing mechanical stopping a hardcoded value from sneaking back in under deadline pressure, and because tokens named after their current appearance instead of their role quietly stop meaning what their name says the moment a second theme needs them to diverge. Fix the naming once, add one CI check, and the rest of the system holds up on its own without anyone needing to remember to be careful.