How-to

Dark Mode Implementation with Design Tokens: Beyond Simple Inversion

Build sophisticated dark mode experiences using design tokens for semantic color mapping, elevation, and accessibility.

FramingUI Team13 min read

Dark Mode Implementation with Design Tokens: Beyond Simple Inversion

Most dark mode implementations fail because they treat it as color inversion: flip white to black, swap light gray for dark gray, call it done. The result looks like a photo negative—text is hard to read, shadows disappear into backgrounds, and color relationships break.

Good dark mode isn't about inverting colors. It's about rethinking elevation, adjusting contrast ratios, and managing semantic relationships across both light and dark contexts. Design tokens make this possible by encoding these relationships once, then deriving both themes from the same semantic layer.

When AI assistants generate UI components using properly structured dark mode tokens, they automatically produce interfaces that work beautifully in both themes without manual color tweaking.

This guide shows you how to build a dark mode system using design tokens, covering color scales, elevation, opacity, and accessibility patterns that work across light and dark themes.

Why Dark Mode Is More Than Color Inversion

Consider this naive implementation:

/* Light mode */
.card {
  background: white;
  color: black;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

/* Dark mode - just invert */
@media (prefers-color-scheme: dark) {
  .card {
    background: black;
    color: white;
    box-shadow: 0 1px 3px rgba(255,255,255,0.1); /* Wrong! */
  }
}

This fails because:

  1. Pure black backgrounds strain eyes in dark environments (use dark gray instead)
  2. White shadows on dark backgrounds look bizarre (dark mode uses lighter surfaces for elevation)
  3. Text contrast is too harsh (white on black is harder to read than light gray on dark gray)
  4. Brand colors lose vibrancy (need adjustment for dark backgrounds)

Semantic Color Architecture

Build a three-tier system: primitives, semantic mappings, and theme-specific overrides.

Tier 1: Primitive Color Scales

Define comprehensive scales for both light and dark themes:

// tokens/primitives/colors.ts
export const colorPrimitives = {
  // Neutral scale for light theme
  neutral: {
    0:   'oklch(1.00 0 0)',    // Pure white
    50:  'oklch(0.98 0 0)',    // Off-white
    100: 'oklch(0.95 0 0)',    // Light background
    200: 'oklch(0.90 0 0)',    // Subtle border
    300: 'oklch(0.82 0 0)',    // Border
    400: 'oklch(0.70 0 0)',    // Disabled text
    500: 'oklch(0.55 0 0)',    // Placeholder
    600: 'oklch(0.45 0 0)',    // Secondary text
    700: 'oklch(0.35 0 0)',    // Primary text (light)
    800: 'oklch(0.25 0 0)',    // Emphasis text
    900: 'oklch(0.15 0 0)',    // Strongest text
    950: 'oklch(0.10 0 0)',    // Near black
  },
  
  // Neutral scale for dark theme (inverted lightness values)
  neutralDark: {
    0:   'oklch(0.10 0 0)',    // Near black background
    50:  'oklch(0.12 0 0)',    // Dark background
    100: 'oklch(0.15 0 0)',    // Elevated surface
    200: 'oklch(0.20 0 0)',    // Subtle border
    300: 'oklch(0.28 0 0)',    // Border
    400: 'oklch(0.40 0 0)',    // Disabled text
    500: 'oklch(0.52 0 0)',    // Placeholder
    600: 'oklch(0.65 0 0)',    // Secondary text
    700: 'oklch(0.75 0 0)',    // Primary text (dark)
    800: 'oklch(0.85 0 0)',    // Emphasis text
    900: 'oklch(0.92 0 0)',    // Strongest text
    950: 'oklch(0.98 0 0)',    // Near white
  },
  
  // Brand colors - adjusted for both themes
  blue: {
    // Light theme variants
    50:  'oklch(0.95 0.02 250)',
    500: 'oklch(0.55 0.15 250)', // Primary
    600: 'oklch(0.48 0.16 250)', // Hover
    
    // Dark theme variants (higher lightness for contrast)
    500Dark: 'oklch(0.65 0.14 250)', // Lighter for dark bg
    600Dark: 'oklch(0.58 0.15 250)',
  },
  
  green: {
    50:  'oklch(0.95 0.02 145)',
    500: 'oklch(0.55 0.12 145)',
    500Dark: 'oklch(0.65 0.12 145)',
  },
  
  red: {
    50:  'oklch(0.95 0.02 25)',
    500: 'oklch(0.55 0.18 25)',
    500Dark: 'oklch(0.65 0.17 25)',
  },
}

Notice how dark mode colors have higher lightness values—this maintains readability on dark backgrounds.

Tier 2: Semantic Color Mappings

Map primitives to intent-based tokens that switch based on theme:

// tokens/semantic/colors.ts
import { colorPrimitives } from '../primitives/colors'

// Light theme semantic colors
export const semanticColorsLight = {
  // Backgrounds
  backgroundPrimary:    colorPrimitives.neutral[0],
  backgroundSecondary:  colorPrimitives.neutral[50],
  backgroundTertiary:   colorPrimitives.neutral[100],
  backgroundInverse:    colorPrimitives.neutral[900],
  
  // Elevated surfaces (higher = lighter in light mode)
  surfaceBase:      colorPrimitives.neutral[0],
  surfaceRaised:    colorPrimitives.neutral[0],
  surfaceOverlay:   colorPrimitives.neutral[0],
  
  // Borders
  borderSubtle:     colorPrimitives.neutral[200],
  borderDefault:    colorPrimitives.neutral[300],
  borderStrong:     colorPrimitives.neutral[400],
  
  // Text
  textPrimary:      colorPrimitives.neutral[900],
  textSecondary:    colorPrimitives.neutral[600],
  textTertiary:     colorPrimitives.neutral[500],
  textDisabled:     colorPrimitives.neutral[400],
  textInverse:      colorPrimitives.neutral[0],
  
  // Interactive elements
  interactivePrimary:       colorPrimitives.blue[500],
  interactivePrimaryHover:  colorPrimitives.blue[600],
  interactiveSecondary:     colorPrimitives.neutral[100],
  
  // Status colors
  statusSuccess:    colorPrimitives.green[500],
  statusWarning:    'oklch(0.65 0.15 70)',
  statusError:      colorPrimitives.red[500],
  statusInfo:       colorPrimitives.blue[500],
}

// Dark theme semantic colors
export const semanticColorsDark = {
  // Backgrounds
  backgroundPrimary:    colorPrimitives.neutralDark[0],
  backgroundSecondary:  colorPrimitives.neutralDark[50],
  backgroundTertiary:   colorPrimitives.neutralDark[100],
  backgroundInverse:    colorPrimitives.neutralDark[900],
  
  // Elevated surfaces (higher = LIGHTER in dark mode - reverses light mode)
  surfaceBase:      colorPrimitives.neutralDark[50],
  surfaceRaised:    colorPrimitives.neutralDark[100],  // Lighter = elevated
  surfaceOverlay:   colorPrimitives.neutralDark[200],  // Lightest = top layer
  
  // Borders (more subtle in dark mode)
  borderSubtle:     colorPrimitives.neutralDark[200],
  borderDefault:    colorPrimitives.neutralDark[300],
  borderStrong:     colorPrimitives.neutralDark[400],
  
  // Text (reduced contrast for comfort)
  textPrimary:      colorPrimitives.neutralDark[900],
  textSecondary:    colorPrimitives.neutralDark[700],
  textTertiary:     colorPrimitives.neutralDark[500],
  textDisabled:     colorPrimitives.neutralDark[400],
  textInverse:      colorPrimitives.neutralDark[0],
  
  // Interactive elements (brighter versions)
  interactivePrimary:       colorPrimitives.blue['500Dark'],
  interactivePrimaryHover:  colorPrimitives.blue['600Dark'],
  interactiveSecondary:     colorPrimitives.neutralDark[200],
  
  // Status colors (adjusted for dark backgrounds)
  statusSuccess:    colorPrimitives.green['500Dark'],
  statusWarning:    'oklch(0.75 0.14 70)',
  statusError:      colorPrimitives.red['500Dark'],
  statusInfo:       colorPrimitives.blue['500Dark'],
}

Key insight: Elevation works differently in dark mode. Light mode uses shadows (darker = elevated). Dark mode uses lighter surfaces (lighter = elevated).

Tier 3: Elevation System

Define elevation tokens that automatically adapt to theme:

// tokens/elevation.ts
export const elevationLight = {
  flat:    'none',
  low:     '0 1px 2px rgba(0, 0, 0, 0.05)',
  medium:  '0 4px 6px rgba(0, 0, 0, 0.07)',
  high:    '0 10px 15px rgba(0, 0, 0, 0.1)',
  highest: '0 20px 25px rgba(0, 0, 0, 0.15)',
}

export const elevationDark = {
  flat:    'none',
  // Dark mode: lighter backgrounds + subtle shadows
  low:     '0 1px 2px rgba(0, 0, 0, 0.3)',
  medium:  '0 4px 6px rgba(0, 0, 0, 0.4)',
  high:    '0 10px 15px rgba(0, 0, 0, 0.5)',
  highest: '0 20px 25px rgba(0, 0, 0, 0.6)',
}

In dark mode, shadows are stronger (higher opacity) because dark backgrounds need more contrast to show depth.

Building a Theme System

Create a theme context that switches between light and dark tokens:

// tokens/theme.ts
import {
  semanticColorsLight,
  semanticColorsDark,
  elevationLight,
  elevationDark,
} from './semantic'

export type Theme = 'light' | 'dark'

export const themes = {
  light: {
    colors: semanticColorsLight,
    elevation: elevationLight,
  },
  dark: {
    colors: semanticColorsDark,
    elevation: elevationDark,
  },
}

export function getThemeTokens(theme: Theme) {
  return themes[theme]
}
// context/ThemeContext.tsx
import { createContext, useContext, useState, useEffect } from 'react'
import { Theme, getThemeTokens } from '@/tokens/theme'

interface ThemeContextValue {
  theme: Theme
  setTheme: (theme: Theme) => void
  tokens: ReturnType<typeof getThemeTokens>
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light')
  
  // Sync with system preference
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    setTheme(mediaQuery.matches ? 'dark' : 'light')
    
    const handler = (e: MediaQueryListEvent) => {
      setTheme(e.matches ? 'dark' : 'light')
    }
    
    mediaQuery.addEventListener('change', handler)
    return () => mediaQuery.removeEventListener('change', handler)
  }, [])
  
  const tokens = getThemeTokens(theme)
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme, tokens }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

Building Theme-Aware Components

Components reference semantic tokens instead of hardcoded colors:

// components/Card.tsx
import { useTheme } from '@/context/ThemeContext'

interface CardProps {
  children: React.ReactNode
  elevated?: boolean
}

export function Card({ children, elevated = false }: CardProps) {
  const { tokens } = useTheme()
  
  return (
    <div style={{
      backgroundColor: elevated 
        ? tokens.colors.surfaceRaised 
        : tokens.colors.surfaceBase,
      border: `1px solid ${tokens.colors.borderDefault}`,
      borderRadius: '0.5rem',
      padding: '1.5rem',
      boxShadow: elevated ? tokens.elevation.medium : tokens.elevation.flat,
    }}>
      {children}
    </div>
  )
}

In light mode, surfaceRaised is white with a shadow. In dark mode, it's a lighter gray (elevated surfaces are lighter in dark themes).

Button Component with Theme Support

// components/Button.tsx
import { useTheme } from '@/context/ThemeContext'

interface ButtonProps {
  children: React.ReactNode
  variant?: 'primary' | 'secondary'
  onClick?: () => void
}

export function Button({ 
  children, 
  variant = 'primary',
  onClick 
}: ButtonProps) {
  const { tokens } = useTheme()
  
  const styles = {
    primary: {
      backgroundColor: tokens.colors.interactivePrimary,
      color: tokens.colors.textInverse,
      border: 'none',
      
      ':hover': {
        backgroundColor: tokens.colors.interactivePrimaryHover,
      },
    },
    secondary: {
      backgroundColor: tokens.colors.interactiveSecondary,
      color: tokens.colors.textPrimary,
      border: `1px solid ${tokens.colors.borderDefault}`,
      
      ':hover': {
        backgroundColor: tokens.colors.backgroundTertiary,
      },
    },
  }
  
  return (
    <button
      onClick={onClick}
      style={{
        ...styles[variant],
        padding: '0.75rem 1.5rem',
        borderRadius: '0.375rem',
        fontSize: '1rem',
        fontWeight: '500',
        cursor: 'pointer',
        transition: 'all 150ms ease',
      }}
    >
      {children}
    </button>
  )
}

Alert Component with Status Colors

// components/Alert.tsx
import { useTheme } from '@/context/ThemeContext'

interface AlertProps {
  children: React.ReactNode
  status: 'success' | 'warning' | 'error' | 'info'
}

export function Alert({ children, status }: AlertProps) {
  const { tokens } = useTheme()
  
  const statusColors = {
    success: tokens.colors.statusSuccess,
    warning: tokens.colors.statusWarning,
    error: tokens.colors.statusError,
    info: tokens.colors.statusInfo,
  }
  
  return (
    <div style={{
      display: 'flex',
      gap: '0.75rem',
      padding: '1rem',
      borderRadius: '0.375rem',
      backgroundColor: tokens.colors.backgroundTertiary,
      borderLeft: `4px solid ${statusColors[status]}`,
    }}>
      <div style={{ 
        color: statusColors[status],
        fontSize: '1.25rem',
      }}>
        {status === 'success' && '✓'}
        {status === 'warning' && '⚠'}
        {status === 'error' && '✕'}
        {status === 'info' && 'ℹ'}
      </div>
      <div style={{
        color: tokens.colors.textPrimary,
        fontSize: '0.875rem',
      }}>
        {children}
      </div>
    </div>
  )
}

CSS Variables Approach

For performance, inject theme tokens as CSS variables:

// components/ThemeProvider.tsx
import { useTheme } from '@/context/ThemeContext'
import { useEffect } from 'react'

export function ThemeStyleInjector() {
  const { theme, tokens } = useTheme()
  
  useEffect(() => {
    const root = document.documentElement
    
    // Inject all color tokens as CSS variables
    Object.entries(tokens.colors).forEach(([key, value]) => {
      root.style.setProperty(`--color-${key}`, value)
    })
    
    // Inject elevation tokens
    Object.entries(tokens.elevation).forEach(([key, value]) => {
      root.style.setProperty(`--elevation-${key}`, value)
    })
    
    // Set theme attribute for CSS targeting
    root.setAttribute('data-theme', theme)
  }, [theme, tokens])
  
  return null
}

Now use CSS variables in components:

/* components/Card.module.css */
.card {
  background-color: var(--color-surfaceBase);
  border: 1px solid var(--color-borderDefault);
  box-shadow: var(--elevation-flat);
}

.cardElevated {
  background-color: var(--color-surfaceRaised);
  box-shadow: var(--elevation-medium);
}

CSS variables update instantly when theme changes—no component re-renders needed.

Theme Toggle Component

// components/ThemeToggle.tsx
import { useTheme } from '@/context/ThemeContext'

export function ThemeToggle() {
  const { theme, setTheme, tokens } = useTheme()
  
  return (
    <button
      onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
      aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
      style={{
        width: '3rem',
        height: '3rem',
        borderRadius: '50%',
        border: `1px solid ${tokens.colors.borderDefault}`,
        backgroundColor: tokens.colors.surfaceRaised,
        cursor: 'pointer',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        fontSize: '1.25rem',
        transition: 'all 200ms ease',
      }}
    >
      {theme === 'light' ? '🌙' : '☀️'}
    </button>
  )
}

Real-World Example: Dashboard Layout

Combining multiple theme-aware components:

// app/dashboard/page.tsx
import { useTheme } from '@/context/ThemeContext'
import { Card } from '@/components/Card'
import { Button } from '@/components/Button'
import { Alert } from '@/components/Alert'

export default function DashboardPage() {
  const { tokens } = useTheme()
  
  return (
    <div style={{
      minHeight: '100vh',
      backgroundColor: tokens.colors.backgroundPrimary,
      color: tokens.colors.textPrimary,
      padding: '2rem',
    }}>
      <header style={{
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: '2rem',
      }}>
        <h1 style={{
          fontSize: '2rem',
          fontWeight: 'bold',
          color: tokens.colors.textPrimary,
        }}>
          Dashboard
        </h1>
        <ThemeToggle />
      </header>
      
      <Alert status="info">
        Your trial expires in 7 days. Upgrade now to keep all features.
      </Alert>
      
      <div style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
        gap: '1.5rem',
        marginTop: '2rem',
      }}>
        <Card elevated>
          <h2 style={{ marginBottom: '0.5rem' }}>Total Revenue</h2>
          <p style={{
            fontSize: '2rem',
            fontWeight: 'bold',
            color: tokens.colors.statusSuccess,
          }}>
            $24,500
          </p>
          <p style={{ color: tokens.colors.textSecondary }}>
            +12% from last month
          </p>
        </Card>
        
        <Card elevated>
          <h2 style={{ marginBottom: '0.5rem' }}>Active Users</h2>
          <p style={{
            fontSize: '2rem',
            fontWeight: 'bold',
          }}>
            1,234
          </p>
          <p style={{ color: tokens.colors.textSecondary }}>
            +8% from last week
          </p>
        </Card>
        
        <Card elevated>
          <h2 style={{ marginBottom: '0.5rem' }}>Pending Tasks</h2>
          <p style={{
            fontSize: '2rem',
            fontWeight: 'bold',
            color: tokens.colors.statusWarning,
          }}>
            18
          </p>
          <Button variant="secondary">View All</Button>
        </Card>
      </div>
    </div>
  )
}

This dashboard automatically adapts to light and dark modes without any theme-specific logic in the component—all handled by semantic tokens.

Using Tokens with AI Assistants

When you provide theme tokens to AI coding assistants, they generate components that work in both modes:

Prompt:

Create a pricing card component with title, price, features list, and CTA button.
Use theme tokens from @/context/ThemeContext.
Should work in both light and dark modes.

AI Output:

import { useTheme } from '@/context/ThemeContext'

interface PricingCardProps {
  title: string
  price: string
  features: string[]
  ctaText: string
  onCTA: () => void
  highlighted?: boolean
}

export function PricingCard({
  title,
  price,
  features,
  ctaText,
  onCTA,
  highlighted = false,
}: PricingCardProps) {
  const { tokens } = useTheme()
  
  return (
    <div style={{
      backgroundColor: highlighted 
        ? tokens.colors.surfaceRaised 
        : tokens.colors.surfaceBase,
      border: `2px solid ${
        highlighted 
          ? tokens.colors.interactivePrimary 
          : tokens.colors.borderDefault
      }`,
      borderRadius: '0.75rem',
      padding: '2rem',
      boxShadow: highlighted ? tokens.elevation.high : tokens.elevation.low,
    }}>
      <h3 style={{
        fontSize: '1.5rem',
        fontWeight: 'bold',
        color: tokens.colors.textPrimary,
        marginBottom: '0.5rem',
      }}>
        {title}
      </h3>
      
      <div style={{
        fontSize: '3rem',
        fontWeight: 'bold',
        color: tokens.colors.interactivePrimary,
        marginBottom: '1.5rem',
      }}>
        {price}
      </div>
      
      <ul style={{
        listStyle: 'none',
        padding: 0,
        marginBottom: '2rem',
      }}>
        {features.map((feature, i) => (
          <li key={i} style={{
            display: 'flex',
            alignItems: 'center',
            gap: '0.5rem',
            marginBottom: '0.75rem',
            color: tokens.colors.textSecondary,
          }}>
            <span style={{ color: tokens.colors.statusSuccess }}>✓</span>
            {feature}
          </li>
        ))}
      </ul>
      
      <button
        onClick={onCTA}
        style={{
          width: '100%',
          padding: '0.75rem',
          backgroundColor: tokens.colors.interactivePrimary,
          color: tokens.colors.textInverse,
          border: 'none',
          borderRadius: '0.375rem',
          fontSize: '1rem',
          fontWeight: '500',
          cursor: 'pointer',
        }}
      >
        {ctaText}
      </button>
    </div>
  )
}

The AI understood the theme system and created a component that automatically adapts to both light and dark modes using semantic color tokens.

FramingUI's Dark Mode System

FramingUI provides a complete dark mode system out of the box:

// framingui.config.ts
import { defineConfig } from 'framingui'

export default defineConfig({
  darkMode: 'class', // or 'media' for system preference
  theme: {
    colors: {
      // Override specific dark mode colors
      dark: {
        primary: 'oklch(0.65 0.15 280)', // Custom brand color for dark mode
        surface: 'oklch(0.12 0.01 280)',  // Slight brand tint on surfaces
      },
    },
  },
})

All FramingUI components support dark mode automatically. When connected to AI assistants via MCP, they can query your theme configuration and generate components that match your exact color system in both modes.

Accessibility Considerations

  1. Maintain contrast ratios: WCAG AA requires 4.5:1 for normal text, 3:1 for large text and UI components in both themes
  2. Test with real users: Some people prefer dark mode for eye strain, others find it harder to read
  3. Respect system preferences: Default to prefers-color-scheme media query
  4. Provide toggle: Allow users to override system preference
  5. Persist preference: Save choice to localStorage

Conclusion

Dark mode with design tokens transforms theme implementation from duplicated CSS to a semantic system where color relationships are defined once and derived for both themes. By encoding elevation, opacity, and semantic intent into tokens:

  • Consistency: Same semantic meaning across themes
  • Maintainability: Change one token, update both themes
  • AI compatibility: Assistants generate theme-aware components automatically
  • Accessibility: Proper contrast ratios guaranteed by token architecture

You can implement this from scratch or use FramingUI's pre-configured dark mode system. Either way, the result is the same: interfaces that look great in both light and dark modes with minimal manual color management.

Further reading:

Ready to build with FramingUI?

Build consistent UI with AI-ready design tokens. No more hallucinated colors or spacing.

Try FramingUI
Share

Related Posts