Nobody decides to break a token system. It breaks the way a footpath breaks a lawn: one person cuts a corner under deadline, the shortcut becomes visible, and the next person takes it because it is visibly there. Six months later the "system" is a folder of aspirational names and a codebase full of raw values.
I keep a small tool for staying honest about this on my own projects:
Token explorer
'use client'
import { useId, useRef, useState } from 'react'
type Tab = 'color' | 'typography' | 'space'
const TABS: Tab[] = ['color', 'typography', 'space']
const COLOR_TOKENS = [
{ name: '--color-bg', label: 'bg' },
{ name: '--color-surface', label: 'surface' },
{ name: '--color-surface-hover', label: 'surface-hover' },
{ name: '--color-border', label: 'border' },
{ name: '--color-border-strong', label: 'border-strong' },
{ name: '--color-fg', label: 'fg' },
{ name: '--color-fg-muted', label: 'fg-muted' },
{ name: '--color-fg-subtle', label: 'fg-subtle' },
{ name: '--color-accent', label: 'accent' },
{ name: '--color-accent-soft', label: 'accent-soft' },
]
const TYPE_TOKENS = [
{ name: 'xs', rem: '0.75rem', px: '12px', cls: 'text-xs' },
{ name: 'sm', rem: '0.875rem', px: '14px', cls: 'text-sm' },
{ name: 'base', rem: '1rem', px: '16px', cls: 'text-base' },
{ name: 'lg', rem: '1.125rem', px: '18px', cls: 'text-lg' },
{ name: 'xl', rem: '1.25rem', px: '20px', cls: 'text-xl' },
{ name: '2xl', rem: '1.5rem', px: '24px', cls: 'text-2xl' },
{ name: '3xl', rem: '1.875rem', px: '30px', cls: 'text-3xl' },
]
const SPACE_TOKENS = [
{ name: '1', px: 4 },
{ name: '2', px: 8 },
{ name: '4', px: 16 },
{ name: '6', px: 24 },
{ name: '8', px: 32 },
{ name: '12', px: 48 },
{ name: '16', px: 64 },
{ name: '24', px: 96 },
]
const MAX_SPACE_PX = 96
function ColorTab({ copied, onCopy }: { copied: string | null; onCopy: (value: string) => void }) {
return (
<div className="grid grid-cols-2 gap-2">
{COLOR_TOKENS.map(({ name, label }) => {
const isCopied = copied === name
return (
<button
key={name}
type="button"
onClick={() => onCopy(name)}
className="group flex items-center gap-3 rounded-md border border-(--color-border) bg-(--color-surface) px-3 py-2.5 text-left transition-colors"
>
<span
className="h-7 w-7 flex-shrink-0 rounded-md border border-(--color-border)"
style={{ background: `var(${name})` }}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs text-(--color-fg)">
{isCopied ? 'Copied' : label}
</span>
{!isCopied && (
<span className="block truncate text-[10px] text-(--color-fg-subtle) opacity-0 transition-opacity group-hover:opacity-100">
{name}
</span>
)}
</span>
</button>
)
})}
</div>
)
}
function TypeTab() {
return (
<div className="divide-y divide-(--color-border) overflow-hidden rounded-md border border-(--color-border)">
{TYPE_TOKENS.map(({ name, rem, px, cls }) => (
<div key={name} className="flex items-center gap-4 bg-(--color-surface) px-4 py-3">
<span className={`${cls} w-16 flex-shrink-0 font-sans leading-none text-(--color-fg)`}>
Aa
</span>
<span className="w-10 flex-shrink-0 text-xs text-(--color-fg-muted)">{name}</span>
<span className="ml-auto text-right text-[10px] text-(--color-fg-subtle) tabular-nums">
{rem} / {px}
</span>
</div>
))}
</div>
)
}
function SpaceTab() {
return (
<div className="flex flex-col gap-2.5">
{SPACE_TOKENS.map(({ name, px }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 flex-shrink-0 text-right text-xs text-(--color-fg-muted) tabular-nums">
{name}
</span>
<div className="flex h-5 flex-1 items-center rounded-md bg-(--color-surface)">
<div
className="h-full rounded-md border border-(--color-accent) bg-(--color-accent-soft)"
style={{ width: `${(px / MAX_SPACE_PX) * 100}%` }}
/>
</div>
<span className="w-10 flex-shrink-0 text-right text-[10px] text-(--color-fg-subtle) tabular-nums">
{px}px
</span>
</div>
))}
</div>
)
}
export default function TokenExplorer() {
const [activeTab, setActiveTab] = useState<Tab>('color')
const [copied, setCopied] = useState<string | null>(null)
const baseId = useId()
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const copyTimer = useRef(0)
function copyToClipboard(value: string) {
// Confirm only after the write succeeds; a denied clipboard shows nothing.
void navigator.clipboard
.writeText(value)
.then(() => {
setCopied(value)
window.clearTimeout(copyTimer.current)
copyTimer.current = window.setTimeout(() => setCopied(null), 1400)
})
.catch(() => {})
}
// Roving tabindex: arrows move focus AND selection (automatic activation).
function onTabKeyDown(e: React.KeyboardEvent, index: number) {
let next: number | null = null
if (e.key === 'ArrowRight') next = (index + 1) % TABS.length
else if (e.key === 'ArrowLeft') next = (index - 1 + TABS.length) % TABS.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = TABS.length - 1
const tab = next === null ? undefined : TABS[next]
if (next === null || !tab) return
e.preventDefault()
setActiveTab(tab)
tabRefs.current[next]?.focus()
}
return (
<div className="mx-auto w-lg max-w-full font-mono text-sm">
<div
role="tablist"
aria-label="Design tokens"
className="mb-5 flex gap-0 border-b border-(--color-border)"
>
{TABS.map((tab, index) => (
<button
key={tab}
ref={(el) => {
tabRefs.current[index] = el
}}
type="button"
role="tab"
id={`${baseId}-tab-${tab}`}
aria-selected={activeTab === tab}
aria-controls={`${baseId}-panel`}
tabIndex={activeTab === tab ? 0 : -1}
onClick={() => setActiveTab(tab)}
onKeyDown={(e) => onTabKeyDown(e, index)}
className={`relative px-4 py-2 text-xs font-medium capitalize transition-colors ${
activeTab === tab ? 'text-(--color-fg)' : 'text-(--color-fg-muted)'
}`}
>
{tab}
{activeTab === tab && (
<span className="absolute bottom-[-1px] left-0 h-[2px] w-full rounded-full bg-(--color-accent)" />
)}
</button>
))}
</div>
<div
role="tabpanel"
id={`${baseId}-panel`}
aria-labelledby={`${baseId}-tab-${activeTab}`}
tabIndex={0}
>
{activeTab === 'color' && <ColorTab copied={copied} onCopy={copyToClipboard} />}
{activeTab === 'typography' && <TypeTab />}
{activeTab === 'space' && <SpaceTab />}
</div>
</div>
)
}
The three kinds of drift
Raw values sneaking in. A #2a2a2a in a component file because looking up the token
name felt slow that day. Individually harmless. Collectively, they mean a theme change no
longer changes the theme.
Near-duplicate tokens. Someone needs a border a touch stronger than border, does not
find one, and mints border-strong-2. Now there are two truths, and every future reader has
to guess which one is real. Duplicates are worse than raw values because they wear the
system's own uniform.
Semantic bypass. The palette has layers: raw scales at the bottom (gray-300), semantic
roles on top (border-muted). Drift is when components reach past the semantic layer and
grab the raw scale directly. It works, right up until the semantic layer needs to remap
(dark mode, a rebrand, a contrast fix) and half the codebase does not come along.
Noticing is a build step, not a vibe
The useful realization for me was that all three kinds are mechanically detectable, and none of them require a platform team.
A lint rule or a grep in CI that counts raw color literals (#, rgb(, oklch() outside
the token definitions catches the first kind. The count matters more than the rule: get it
to zero once, then fail the build when it rises. A ratchet, not an aspiration.
For the second kind, render your tokens. A page that draws every token as a swatch with its name and value makes near-duplicates embarrassing in a way a YAML file never will. Two almost-identical grays sitting adjacent on screen ask the question themselves. This is why my token browser above reads the site's live CSS variables instead of a static list: it cannot lie about what is actually shipped.
The third kind is naming discipline plus one rule: components may only reference the semantic layer. If that is enforceable in your setup (a lint rule on allowed variable prefixes gets you most of the way), enforce it. If not, review for it, and keep the raw scale physically separate so reaching past the seam is at least visible in the import.
Keep the palette small enough to police
Every one of these mechanisms gets cheaper as the token count shrinks. This site runs on about a dozen color tokens, and drift is easy to spot precisely because any new value has nowhere to hide. If your system has three hundred tokens, the drift is already inside the definitions. For building the scales themselves, I use an OKLCH ramp explorer so lightness steps stay perceptually even and contrast is checked at mint time, which is one less reason for anyone to go off-palette later.