How to Manage Light and Dark Theme Colors with CSS
When a site supports both light and dark themes, hard-coded colors scattered across its CSS can leave a light background, unreadable label, or mismatched button after the theme changes. Grouping colors by their roles makes both versions easier to maintain. This guide shows a practical approach using CSS custom properties and the visitor’s system or browser preference.
Keep the same CSS variable names for both themes
A CSS custom property (often called a CSS variable) is a named value that can be reused in styles. For example, --bg identifies the page background, --text identifies body text, and --surface identifies the background of a card or panel. With var(--bg), an element uses the color assigned to that role rather than repeating a literal color value.
Use the same names in light and dark themes; change their values, not the names or the selectors that consume them. Define button background and button text separately so the foreground stays legible when its background changes. A dark theme does not need to be a mechanical inversion of the light palette.
Define the colors and follow the user’s color-scheme preference
The following CSS handles a page, a card, and a button. The :root rule establishes the light defaults. The @media (prefers-color-scheme: dark) query overrides the values when the user’s operating system or browser requests a dark color scheme. This is automatic preference-based switching; it does not require JavaScript.
:root {
color-scheme: light dark;
--bg: rgb(248 250 252);
--surface: rgb(255 255 255);
--text: rgb(30 41 59);
--button-bg: rgb(29 78 216);
--button-text: rgb(255 255 255);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: rgb(15 23 42);
--surface: rgb(30 41 59);
--text: rgb(226 232 240);
--button-bg: rgb(147 197 253);
--button-text: rgb(15 23 42);
}
}
body { background-color: var(--bg); color: var(--text); }
.card { background-color: var(--surface); }
.button {
background-color: var(--button-bg);
color: var(--button-text);
}
The example uses rgb(), which specifies colors by red, green, and blue channel values. Here are the equivalent six-digit HEX color codes (hexadecimal representations) for each role:
| Color role | Light | Dark |
|---|---|---|
Page background (--bg) |
#F8FAFC | #0F172A |
Card background (--surface) |
#FFFFFF | #1E293B |
Body text (--text) |
#1E293B | #E2E8F0 |
Button background (--button-bg) |
#1D4ED8 | #93C5FD |
Button text (--button-text) |
#FFFFFF | #0F172A |
The elements refer only to the role names, so a theme can be adjusted by editing its variable definitions. The declaration color-scheme: light dark also tells the browser that the page supports both schemes, allowing browser-rendered elements such as form controls and scrollbars to match. It does not, by itself, assign colors to your custom cards, buttons, or page sections.
Check contrast and behavior in each theme
Check every foreground against the background it actually appears on. In this example, body text against the page background has a contrast ratio of approximately 13.98:1 in light mode and 14.48:1 in dark mode. Button labels against their button backgrounds measure about 6.70:1 and 9.90:1 respectively. These combinations exceed the WCAG (Web Content Accessibility Guidelines) Level AA threshold of 4.5:1 for normal-sized text. The figures apply to the exact opaque color pairs shown here; translucent overlays and image backgrounds need their own checks.
Also review text inside cards, links, form controls, icons, hover states, and keyboard focus indicators in both themes. prefers-color-scheme responds to the system or browser setting; it does not build an in-page light/dark toggle or remember a visitor’s choice. If a manual toggle is required, use JavaScript or another state mechanism to apply the chosen variable overrides, and decide how that preference will be stored.