FontAlternatives has hundreds of components across thousands of pages. Every page needs consistent colors, spacing, and typography. I built it without Figma, without Storybook, just CSS custom properties.
80+ tokens. Light and dark modes. Zero design tools.
Why design tokens
Design tokens are named values for visual properties. Instead of #1a1a1a, you use --color-text-primary.
Benefits:
- Consistency: Same color used everywhere
- Theming: Change one value, update everywhere
- Communication:
--spacing-mdis clearer than16px - Refactoring: Find-and-replace by name, not value
The token categories
I organized tokens into six categories:
:root {
/* Colors */
--color-text-primary: ...;
--color-bg-primary: ...;
--color-accent-orange: ...;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Typography */
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
/* Borders */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
}
Color system
Colors are the most complex. I have semantic colors (what they mean) and palette colors (the actual values):
:root {
/* Palette - the actual colors */
--palette-gray-50: #fafafa;
--palette-gray-100: #f4f4f5;
--palette-gray-200: #e4e4e7;
--palette-gray-300: #d4d4d8;
--palette-gray-400: #a1a1aa;
--palette-gray-500: #71717a;
--palette-gray-600: #52525b;
--palette-gray-700: #3f3f46;
--palette-gray-800: #27272a;
--palette-gray-900: #18181b;
--palette-orange-400: #fb923c;
--palette-orange-500: #f97316;
--palette-orange-600: #ea580c;
/* Semantic - what they're used for */
--color-text-primary: var(--palette-gray-900);
--color-text-secondary: var(--palette-gray-600);
--color-text-tertiary: var(--palette-gray-400);
--color-bg-primary: white;
--color-bg-secondary: var(--palette-gray-50);
--color-bg-tertiary: var(--palette-gray-100);
--color-border-primary: var(--palette-gray-200);
--color-border-secondary: var(--palette-gray-300);
--color-accent-orange: var(--palette-orange-500);
--color-accent-orange-hover: var(--palette-orange-600);
}
Semantic tokens reference palette tokens. This means:
- Components use
--color-text-primary(semantic) - Themes redefine which palette color that maps to
- The palette stays constant
Dark mode
Dark mode swaps the semantic mappings:
.dark {
--color-text-primary: var(--palette-gray-100);
--color-text-secondary: var(--palette-gray-300);
--color-text-tertiary: var(--palette-gray-500);
--color-bg-primary: var(--palette-gray-900);
--color-bg-secondary: var(--palette-gray-800);
--color-bg-tertiary: var(--palette-gray-700);
--color-border-primary: var(--palette-gray-700);
--color-border-secondary: var(--palette-gray-600);
/* Accent colors might need adjustment for contrast */
--color-accent-orange: var(--palette-orange-400);
--color-accent-orange-hover: var(--palette-orange-500);
}
Components don’t change. Only the token values change.
Integrating with Tailwind
Tailwind 4.x uses CSS-based configuration. I expose tokens as Tailwind utilities:
@theme {
/* Colors */
--color-text-primary: var(--color-text-primary);
--color-text-secondary: var(--color-text-secondary);
--color-text-tertiary: var(--color-text-tertiary);
--color-bg-primary: var(--color-bg-primary);
--color-bg-secondary: var(--color-bg-secondary);
--color-bg-tertiary: var(--color-bg-tertiary);
--color-border-primary: var(--color-border-primary);
--color-border-secondary: var(--color-border-secondary);
--color-accent-orange: var(--color-accent-orange);
}
Now I can use bg-bg-primary, text-text-secondary, border-border-primary in markup.
The theme toggle
Theme switching is a class toggle on the document:
function toggleTheme(): void {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
// Initialize from storage or system preference
function initTheme(): void {
const stored = localStorage.getItem('theme');
if (stored) {
document.documentElement.classList.toggle('dark', stored === 'dark');
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
}
System preference is respected. User override is stored.
Preventing flash
The theme must apply before render to avoid flash of wrong colors:
<head>
<script>
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
Inline script runs before CSS parses. No flash.
Spacing scale
Consistent spacing makes layouts feel cohesive:
:root {
--spacing-px: 1px;
--spacing-0: 0;
--spacing-0.5: 0.125rem; /* 2px */
--spacing-1: 0.25rem; /* 4px */
--spacing-2: 0.5rem; /* 8px */
--spacing-3: 0.75rem; /* 12px */
--spacing-4: 1rem; /* 16px */
--spacing-5: 1.25rem; /* 20px */
--spacing-6: 1.5rem; /* 24px */
--spacing-8: 2rem; /* 32px */
--spacing-10: 2.5rem; /* 40px */
--spacing-12: 3rem; /* 48px */
--spacing-16: 4rem; /* 64px */
}
I use Tailwind’s scale (4px base). Familiar to anyone who knows Tailwind.
Typography tokens
The primary font is Inter, chosen for its large x-height and excellent screen readability. Font families, sizes, and line heights:
:root {
/* Families */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
/* Line heights */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* Font weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
}
Component patterns
With tokens defined, components are straightforward:
.card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: var(--spacing-4);
}
.card-title {
color: var(--color-text-primary);
font-size: var(--text-lg);
font-weight: var(--font-semibold);
margin-bottom: var(--spacing-2);
}
.card-body {
color: var(--color-text-secondary);
font-size: var(--text-base);
line-height: var(--leading-normal);
}
No magic numbers. Every value has a name.
Token documentation
I document tokens in CSS comments:
/**
* Color Tokens
*
* Usage:
* - text-primary: Main text content
* - text-secondary: Supporting text, descriptions
* - text-tertiary: Muted text, captions
*
* - bg-primary: Page background
* - bg-secondary: Card backgrounds
* - bg-tertiary: Hover states, code blocks
*
* - accent-orange: CTAs, links, highlights
*/
No external documentation. The CSS is the source of truth.
Validation
I have a script that checks token usage:
async function validateTokenUsage(): Promise<void> {
const cssFiles = await glob('src/**/*.css');
const astroFiles = await glob('src/**/*.astro');
const usedTokens = new Set<string>();
const definedTokens = new Set<string>();
// Extract defined tokens
for (const file of cssFiles) {
const content = await readFile(file, 'utf-8');
const matches = content.matchAll(/--[\w-]+(?=:)/g);
for (const match of matches) {
definedTokens.add(match[0]);
}
}
// Extract used tokens
for (const file of [...cssFiles, ...astroFiles]) {
const content = await readFile(file, 'utf-8');
const matches = content.matchAll(/var\((--[\w-]+)\)/g);
for (const match of matches) {
usedTokens.add(match[1]);
}
}
// Find undefined tokens
for (const token of usedTokens) {
if (!definedTokens.has(token)) {
console.error(`Undefined token: ${token}`);
}
}
// Find unused tokens
for (const token of definedTokens) {
if (!usedTokens.has(token) && !token.startsWith('--palette-')) {
console.warn(`Unused token: ${token}`);
}
}
}
Catches typos and dead tokens.
Tradeoffs
What I gained:
- Consistent visual language
- Easy theming (dark mode took an afternoon)
- Self-documenting styles
- No design tool dependency
What I lost:
- Initial setup time
- Learning curve for contributors
- Verbosity (var() everywhere)
What I’d do differently:
- Start with fewer tokens, expand as needed
- Use a CSS preprocessor for token generation
- Add more descriptive names (
--color-text-on-dark-bgvs--color-text-primary)
The full token count
| Category | Tokens |
|---|---|
| Colors (semantic) | 18 |
| Colors (palette) | 24 |
| Spacing | 16 |
| Typography | 20 |
| Borders | 8 |
| Shadows | 4 |
| Transitions | 3 |
| Total | 93 |
Every visual decision has a name. Every name has a purpose.
These tokens also power the live font preview system, where hover states swap font families without layout shift.