The problem with tokens in two places
A design system's tokens, the palette, the spacing scale, the type scale, the radii and shadows, tend to exist twice: once as CSS custom properties the site actually uses, and once in a JSON or TypeScript file that tooling reads. The two drift. A designer adds a colour to the CSS; the lint config does not know about it; the check that was supposed to stop off-system colours passes because it never knew the system.
The fix is to have one file, and to have it be the CSS, because the CSS is the thing that ships. Everything else parses it.
The tokens file
/* app/styles/tokens.css */
:root {
/* colour */
--ink: #1a1a1a;
--paper: #fffdf7;
--pop-amber: #f5b83d;
--pop-pink: #f27ba9;
--pop-mint: #8fd9b6;
--pop-shadow: #1a1a1a;
/* spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
/* type scale */
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
--text-3xl: 2.75rem;
/* structure */
--border-control: 2px;
--radius-control: 0.75rem;
--shadow-pop: 4px 4px 0 0 var(--pop-shadow);
}Plain CSS. Flat names in a few groups, values as the units the site uses, comments as group headers. No preprocessor, no build step required to use it. Imported once in the root layout. Every component reads var(--space-4) and never writes 1rem.
Dark mode and themes are separate blocks redefining the same names under [data-theme] or a media query, so component code never changes.
The Next.js side
Nothing special. CSS Modules and global styles reference the variables. Where a component needs a token value in JavaScript (a canvas, a chart, an inline SVG fill), read it at runtime:
const amber = getComputedStyle(document.documentElement).getPropertyValue('--pop-amber').trim()Or, for server-rendered values, parse the same file at build with a tiny loader and export a typed object. We do the second for the handful of places that need it (Open Graph image generation, email templates) via a script that runs before build and writes lib/generated/tokens.ts. The CSS is still the source; the TypeScript is generated from it.
The Python checker
The checker's job is to enforce three rules across the codebase in CI:
- No raw values where a token exists. No
#f5b83din a component; usevar(--pop-amber). Nomargin: 16px; usevar(--space-4). - No unknown tokens.
var(--space-5)when there is no--space-5is a bug that renders as nothing. - Tokens that exist are used (a warning, not a failure): a token nobody references is either dead or new.
Python because it is available on every CI runner, has no dependency on the Node toolchain, and a regex-driven script is thirty lines. It parses tokens.css for definitions and every other CSS and TSX file for usage.
#!/usr/bin/env python3
"""Enforce design tokens: definitions in tokens.css, usage everywhere else."""
import re, sys, pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
TOKENS = ROOT / "app/styles/tokens.css"
SCAN = [ROOT / "app", ROOT / "components"]
EXTS = {".css", ".tsx", ".ts"}
DEF_RE = re.compile(r"^\s*--([a-z0-9-]+)\s*:\s*([^;]+);", re.M)
USE_RE = re.compile(r"var\(\s*--([a-z0-9-]+)")
HEX_RE = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b")
PX_RE = re.compile(r"(?<![\w-])(\d+)px\b")
defs = {m.group(1): m.group(2).strip() for m in DEF_RE.finditer(TOKENS.read_text())}
known_hex = {v.lower() for v in defs.values() if v.startswith("#")}
allowed_px = {"0", "1", "2"} # hairlines and the 2px control border
errors, used = [], set()
for base in SCAN:
for p in base.rglob("*"):
if p.suffix not in EXTS or p == TOKENS or "generated" in p.parts:
continue
text = p.read_text()
for m in USE_RE.finditer(text):
used.add(m.group(1))
if m.group(1) not in defs:
errors.append(f"{p.relative_to(ROOT)}: unknown token --{m.group(1)}")
if p.suffix == ".css":
for m in HEX_RE.finditer(text):
if m.group(0).lower() in known_hex:
errors.append(f"{p.relative_to(ROOT)}: raw colour {m.group(0)} has a token")
for m in PX_RE.finditer(text):
if m.group(1) not in allowed_px:
errors.append(f"{p.relative_to(ROOT)}: raw {m.group(0)}; use a spacing token")
unused = sorted(set(defs) - used)
for e in errors: print("ERROR", e)
for u in unused: print("WARN unused token --" + u)
print(f"{len(defs)} tokens, {len(errors)} errors, {len(unused)} unused")
sys.exit(1 if errors else 0)Run as npm run check:tokens (a package.json script calling python3 scripts/check-tokens.py) and in CI before build. It fails on unknown tokens and on raw values that have a token; it warns on unused tokens.
What it catches
In practice, three things, repeatedly:
- A colour pasted from a design file as hex instead of via its token, which would have silently diverged when the palette changed.
- A typo in a token name (
--space-4written as--spacing-4), which CSS renders as an unset property with no error anywhere. - Tokens added for a feature that was later removed, which would otherwise accumulate.
It does not catch semantic misuse (using --pop-pink where the design said amber). That is a review question, not a lint one.
Extending it
- Parse
theme.json-style outputs or Tailwind config from the same CSS so every tool agrees. - Emit a Markdown table of tokens into
docs/on each run, so the design system has documentation that cannot be stale. - Check contrast ratios between foreground and background tokens with a few lines of colour maths, and fail below the accessibility threshold.
Where this sits
This is how our own site's design system is enforced, and it is the setup we use on Next.js builds with a defined visual language: one CSS file that is the truth, a runtime that reads it, and a small script that refuses to let anyone route around it. The Python is incidental; the point is that the checker parses the CSS rather than a copy of it.