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:
- Pure black backgrounds strain eyes in dark environments (use dark gray instead)
- White shadows on dark backgrounds look bizarre (dark mode uses lighter surfaces for elevation)
- Text contrast is too harsh (white on black is harder to read than light gray on dark gray)
- 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
- Maintain contrast ratios: WCAG AA requires 4.5:1 for normal text, 3:1 for large text and UI components in both themes
- Test with real users: Some people prefer dark mode for eye strain, others find it harder to read
- Respect system preferences: Default to
prefers-color-schememedia query - Provide toggle: Allow users to override system preference
- 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: