The short version
- A CSS variables theme works best in two layers: palette variables that hold raw colours and semantic variables that describe what each colour is for.
- Components should only ever read semantic variables such as --color-text or --color-surface, never palette values directly.
- Dark mode is then a matter of pointing the semantic variables at different palette values, under a media query or a data attribute.
- The light-dark() function and the color-scheme property let one declaration hold both modes in current browsers.
- A small inline script in the head that sets the saved theme before the page paints prevents the flash of the wrong theme.
On this page
A CSS variables theme is a set of custom properties, such as --color-surface and --color-text, that every component reads instead of hard coded colours. To change the theme you change the variables, usually on :root or on a data-theme attribute, and the whole interface follows. The trick that makes it scale is splitting the variables into two layers: a palette of raw colours, and semantic roles that point at that palette.
Two layers: palette and roles
Palette variables are named for what they are: --violet-600, --ink-900. Semantic variables are named for what they do: --color-accent, --color-text-muted. Components read only the second kind. When dark mode arrives, you repoint the roles and leave every component untouched. This is the same idea as design tokens, expressed directly in CSS.
--violet-600: #7c3aedWho reads it
Changes when
--color-accent: var(--violet-600)Who reads it
Changes when
Example
--button-bg: var(--color-accent)Who reads it
Changes when
:root {
/* Palette: raw values, named for what they are */
--paper-50: #faf8f5;
--paper-100: #f1ede6;
--ink-900: #14121f;
--ink-700: #3d3a4f;
--ink-500: #6b6880;
--violet-600: #6d28d9;
--violet-300: #c4b5fd;
--night-950: #0d0b16;
--night-900: #17142a;
--night-800: #221e3a;
/* Roles: what each colour is for (light mode) */
--color-bg: var(--paper-50);
--color-surface: var(--paper-100);
--color-text: var(--ink-900);
--color-text-muted: var(--ink-500);
--color-border: color-mix(in oklab, var(--ink-900) 12%, transparent);
--color-accent: var(--violet-600);
--color-on-accent: #ffffff;
}
body { background: var(--color-bg); color: var(--color-text); }
.card { background: var(--color-surface); border: 1px solid var(--color-border); }
.button { background: var(--color-accent); color: var(--color-on-accent); }Naming roles that survive a redesign
A role name should still be true after the colours change. --light-grey stops being light in dark mode, and --blue lies the day the brand turns green. Name the job and the pairing instead, so whoever reads the stylesheet next knows which colour goes on which.
Names that break
--blue,--purple-button--light-grey-bg--white-text--dark-border
Names that last
--color-accent,--color-on-accent--color-surface--color-text--color-border
Adding dark mode with CSS variables
Dark mode repoints the roles. Respect the operating system setting by default, and let a data-theme attribute override it when the reader chooses. Set color-scheme too, so scrollbars, form controls and the default canvas match.
:root { color-scheme: light; }
/* Follow the system unless the reader picked light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: var(--night-950);
--color-surface: var(--night-900);
--color-text: #f2f0fa;
--color-text-muted: #a5a1bd;
--color-border: color-mix(in oklab, #ffffff 12%, transparent);
--color-accent: var(--violet-300);
--color-on-accent: var(--ink-900);
}
}
/* The reader picked dark explicitly */
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: var(--night-950);
--color-surface: var(--night-900);
--color-text: #f2f0fa;
--color-text-muted: #a5a1bd;
--color-border: color-mix(in oklab, #ffffff 12%, transparent);
--color-accent: var(--violet-300);
--color-on-accent: var(--ink-900);
}light-dark() removes the repetition.Dark mode is not inverted light mode. Surfaces get lighter as they rise, accents lighten, and shadows give way to borders. Dark mode design and dark mode gradients cover the visual side.
The light-dark() shortcut
Current browsers support light-dark(), which takes a light value and a dark value and picks one based on the element's color-scheme. Set color-scheme: light dark on the root to follow the system, or force one with the attribute, and each role becomes a single line: --color-bg: light-dark(var(--paper-50), var(--night-950)). Keep the longer form if you must support browsers from before 2024.
A theme toggle that doesn't flash
If the saved choice is applied after the page renders, readers who picked dark see a white flash on every load. Set the attribute in a tiny inline script in the head, before any stylesheet paints the body.
<head>
<script>
(function () {
var saved = localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
document.documentElement.dataset.theme = saved;
}
})();
</script>
<link rel="stylesheet" href="/styles.css">
</head>dataset.theme and save the value to localStorage.- 1
Offer three choices
Light, dark and system. Removing the attribute returns control to
prefers-color-scheme. - 2
Save only explicit choices
Store
lightordark; delete the key for system, so a later change in the operating system is respected. - 3
Avoid transitions on load
If colours transition, enable the transition only after the first paint, or the page animates from light to dark as it loads.
Brand themes beyond light and dark
The same two layers handle several brands in one codebase. Each brand provides its own palette and role values under a data-brand attribute, and modes still work on top. With color-mix() you can derive hover states and tints from one accent per brand, as shown in color-mix() in CSS.
[data-brand="harbour"] {
--color-accent: #0f766e;
--gradient-hero: linear-gradient(150deg, #042f2e 0%, #0f766e 55%, #5eead4 100%);
}
[data-brand="ember"] {
--color-accent: #c2410c;
--gradient-hero: linear-gradient(150deg, #431407 0%, #c2410c 55%, #fdba74 100%);
}
.button:hover { background: color-mix(in oklab, var(--color-accent), black 12%); }
.hero { background: var(--gradient-hero); }Because custom properties inherit, a theme does not have to cover the whole page. Put data-theme="dark" on one section, such as a promotional band or a footer, and everything inside it reads the dark roles while the rest of the page stays light. Write the dark selector as [data-theme="dark"] rather than :root[data-theme="dark"] if you want that to work, and set color-scheme on the section as well.
Keep the web and your designs on one palette
A theme in CSS is only half of a brand. The same colours should appear in your social posts, decks and emails. Gradiently's brand kit holds your palettes, logos and heading and body fonts, and the Designer applies it when it lays out a design, so the post you make on Monday uses the same accent as your site. If you are still choosing those colours, start with how to choose brand colours.
Questions people ask
How do I make a theme with CSS variables?
Define palette variables with raw colours, then semantic variables such as --color-bg that point at them. Components read only the semantic ones, and themes repoint them.
How do I add dark mode with CSS variables?
Override the semantic variables inside @media (prefers-color-scheme: dark) and under a [data-theme="dark"] selector for readers who choose it.
How do I stop the theme flashing on page load?
Read the saved theme in a small inline script in the head and set the attribute before the stylesheet paints the page.
What does light-dark() do in CSS?
It returns its first value in light mode and its second in dark mode, based on the element's color-scheme. It works in current major browsers.
Can I store a gradient in a CSS variable?
Yes. Store the whole gradient as the value and use it in background. To animate between gradients, register the stops with @property.
Written by Gradiently
The team behind Gradiently, a design tool built around Marks: living gradients that make everything you design look like yours.
See our profile