Blog
css

80+ CSS Custom Properties for a Scalable Design System (No Figma)

How I built a design token system with 80+ CSS custom properties for consistent styling across light and dark modes using Tailwind 4, without design tools.

Mladen Ruzicic
Mladen Ruzicic
6 min

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-md is clearer than 16px
  • 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-bg vs --color-text-primary)

The full token count

CategoryTokens
Colors (semantic)18
Colors (palette)24
Spacing16
Typography20
Borders8
Shadows4
Transitions3
Total93

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.

Explore on FontAlternatives

#css#design-system#tailwind#theming

More from the blog