Dark mode becomes messy when tokens are not structured from the beginning.
At first, it feels simple.
You define a few CSS variables:
:root {
--background: #ffffff;
--text: #111827;
--primary: #2563eb;
}
Then later you add dark mode:
[data-theme="dark"] {
--background: #020617;
--text: #f8fafc;
--primary: #60a5fa;
}
This works for a small project.
But as the UI grows, the theme becomes harder to manage.
You need tokens for cards, borders, muted text, buttons, hover states, danger states, success states, warning states, disabled states, focus rings, spacing, radius, and typography.
If everything lives in one flat color list, dark mode quickly becomes difficult to maintain.
The problem is not dark mode itself.
The problem is unstructured tokens.
Start with a theme object
A better approach is to structure the theme from the beginning.
Instead of thinking only in CSS variables, start with a theme object.
const theme = {
light: {
colors: {},
states: {},
spacing: {},
radius: {},
fontSizes: {},
accessibility: {}
},
dark: {
colors: {},
states: {},
spacing: {},
radius: {},
fontSizes: {},
accessibility: {}
}
};
This gives the system clear groups.
Colors are not mixed with spacing.
Spacing is not mixed with radius.
Accessibility checks are not hidden inside component code.
Each part of the theme has a job.
Separate light and dark modes
Light and dark mode should use the same token names.
Only the values should change.
const theme = {
light: {
colors: {
background: "#ffffff",
foreground: "#111827",
surface: "#f8fafc",
surfaceElevated: "#ffffff",
border: "#e5e7eb",
muted: "#64748b",
primary: "#2563eb",
onPrimary: "#ffffff",
danger: "#dc2626",
onDanger: "#ffffff",
warning: "#facc15",
onWarning: "#111827",
success: "#16a34a",
onSuccess: "#ffffff"
}
},
dark: {
colors: {
background: "#020617",
foreground: "#f8fafc",
surface: "#0f172a",
surfaceElevated: "#1e293b",
border: "#334155",
muted: "#94a3b8",
primary: "#60a5fa",
onPrimary: "#0f172a",
danger: "#f87171",
onDanger: "#450a0a",
warning: "#facc15",
onWarning: "#111827",
success: "#4ade80",
onSuccess: "#052e16"
}
}
};
The important part is consistency.
Both modes have:
background
foreground
surface
surfaceElevated
border
muted
primary
onPrimary
danger
onDanger
warning
onWarning
success
onSuccess
The component should not care whether the current mode is light or dark.
It should only use the semantic token.
Use semantic tokens, not raw color names
Avoid building components around names like this:
--blue-600
--gray-900
--yellow-400
Those names describe color values.
They do not describe UI roles.
A button does not need to know that it is blue.
It needs to know that it is primary.
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
A warning alert does not need to know that it is yellow.
It needs to know that it is warning.
.alert-warning {
background: var(--color-warning);
color: var(--color-on-warning);
}
This makes dark mode easier because the component contract stays stable.
Only the token values change.
Keep states separate
Interactive states should not be random values inside components.
They should be part of the token system.
const theme = {
light: {
states: {
hover: "rgba(15, 23, 42, 0.06)",
pressed: "rgba(15, 23, 42, 0.12)",
focus: "#2563eb",
disabledBackground: "#e5e7eb",
disabledForeground: "#94a3b8"
}
},
dark: {
states: {
hover: "rgba(248, 250, 252, 0.08)",
pressed: "rgba(248, 250, 252, 0.14)",
focus: "#60a5fa",
disabledBackground: "#1e293b",
disabledForeground: "#64748b"
}
}
};
This helps avoid a common problem:
one button has one hover color, another button has another hover color, and a third component invents a new disabled color.
When states are tokens, components become more consistent.
.button:hover {
background: var(--state-hover);
}
.button:focus-visible {
outline: 2px solid var(--state-focus);
}
.button:disabled {
background: var(--state-disabled-background);
color: var(--state-disabled-foreground);
}
States should be designed, not guessed.
Keep spacing independent from color mode
Not every token needs separate light and dark values.
Spacing usually does not change between modes.
const spacing = {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem"
};
You can keep spacing outside the light/dark mode object:
const theme = {
spacing: {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem"
},
modes: {
light: {},
dark: {}
}
};
This keeps the structure clean.
Color changes by mode.
Spacing usually stays global.
Do the same for radius
Radius tokens should also be separate.
const radius = {
sm: "0.25rem",
md: "0.5rem",
lg: "0.75rem",
xl: "1rem",
full: "9999px"
};
Then components can use consistent shape values:
.card {
border-radius: var(--radius-lg);
}
.button {
border-radius: var(--radius-md);
}
.badge {
border-radius: var(--radius-full);
}
This avoids magic values like:
border-radius: 7px;
border-radius: 11px;
border-radius: 999px;
A design system should make repetition intentional.
Keep font sizes structured
Typography also needs structure.
const fontSizes = {
xs: "0.75rem",
sm: "0.875rem",
base: "1rem",
lg: "1.125rem",
xl: "1.25rem",
"2xl": "1.5rem",
"3xl": "1.875rem"
};
Then UI text can follow a consistent scale:
.caption {
font-size: var(--font-size-xs);
}
.body {
font-size: var(--font-size-base);
}
.heading {
font-size: var(--font-size-2xl);
}
Font-size tokens usually do not need light and dark versions.
But they still belong in the same token system.
Add accessibility checks as part of the structure
Accessibility should not be an afterthought.
A theme system should know which foreground/background pairs are intended to work together.
For example:
const accessibility = {
pairs: [
["foreground", "background"],
["muted", "background"],
["foreground", "surface"],
["primary", "onPrimary"],
["danger", "onDanger"],
["warning", "onWarning"],
["success", "onSuccess"]
]
};
Or better, store the relationship directly with the color roles.
const theme = {
light: {
colors: {
primary: "#2563eb",
onPrimary: "#ffffff"
},
accessibility: {
primary: {
background: "primary",
foreground: "onPrimary"
}
}
}
};
This makes the purpose clear.
The system is not just storing colors.
It is storing readable relationships.
Generate CSS variables from the structure
Once the theme object is structured, CSS variables become an output.
:root {
--color-background: #ffffff;
--color-foreground: #111827;
--color-surface: #f8fafc;
--color-border: #e5e7eb;
--color-primary: #2563eb;
--color-on-primary: #ffffff;
--space-md: 1rem;
--radius-md: 0.5rem;
--font-size-base: 1rem;
}
:root[data-theme="dark"] {
--color-background: #020617;
--color-foreground: #f8fafc;
--color-surface: #0f172a;
--color-border: #334155;
--color-primary: #60a5fa;
--color-on-primary: #0f172a;
}
The CSS variable layer is the runtime layer.
The theme object is the source structure.
This makes it easier to export the same system to different places:
CSS variables
Tailwind config
React theme object
React Native theme object
DTCG tokens
Figma tokens
The source stays organized.
The outputs can change.
Component code becomes simpler
When tokens are structured well, component CSS becomes boring.
That is a good thing.
.card {
background: var(--color-surface);
color: var(--color-foreground);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.button-primary {
background: var(--color-primary);
color: var(--color-on-primary);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
}
.text-muted {
color: var(--color-muted);
}
The component does not invent colors.
It uses the token contract.
A simple structure to start with
A practical theme structure can look like this:
const tokens = {
modes: {
light: {
colors: {
background: "#ffffff",
foreground: "#111827",
surface: "#f8fafc",
border: "#e5e7eb",
muted: "#64748b",
primary: "#2563eb",
onPrimary: "#ffffff",
danger: "#dc2626",
onDanger: "#ffffff",
warning: "#facc15",
onWarning: "#111827",
success: "#16a34a",
onSuccess: "#ffffff"
},
states: {
hover: "rgba(15, 23, 42, 0.06)",
pressed: "rgba(15, 23, 42, 0.12)",
focus: "#2563eb",
disabledBackground: "#e5e7eb",
disabledForeground: "#94a3b8"
}
},
dark: {
colors: {
background: "#020617",
foreground: "#f8fafc",
surface: "#0f172a",
border: "#334155",
muted: "#94a3b8",
primary: "#60a5fa",
onPrimary: "#0f172a",
danger: "#f87171",
onDanger: "#450a0a",
warning: "#facc15",
onWarning: "#111827",
success: "#4ade80",
onSuccess: "#052e16"
},
states: {
hover: "rgba(248, 250, 252, 0.08)",
pressed: "rgba(248, 250, 252, 0.14)",
focus: "#60a5fa",
disabledBackground: "#1e293b",
disabledForeground: "#64748b"
}
}
},
spacing: {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem"
},
radius: {
sm: "0.25rem",
md: "0.5rem",
lg: "0.75rem",
xl: "1rem",
full: "9999px"
},
fontSizes: {
xs: "0.75rem",
sm: "0.875rem",
base: "1rem",
lg: "1.125rem",
xl: "1.25rem",
"2xl": "1.5rem"
},
accessibility: {
contrastPairs: [
["foreground", "background"],
["muted", "background"],
["foreground", "surface"],
["onPrimary", "primary"],
["onDanger", "danger"],
["onWarning", "warning"],
["onSuccess", "success"]
]
}
};
This is not the only possible structure.
But it gives the system a clear shape.
Final thought
Dark mode is not hard because dark colors are hard.
Dark mode becomes hard when the token system has no structure.
A good theme structure separates:
colors
states
spacing
radius
font sizes
accessibility checks
It also keeps light and dark mode using the same semantic names.
Components should not need to know whether the current theme is light or dark.
They should only know the role they need:
background: var(--color-primary);
color: var(--color-on-primary);
The theme should define the values.
The component should use the contract.
That is how dark mode stays maintainable as the UI grows.
Top comments (0)