CSS Custom Properties for Colors and Themes
How to store palette colors as CSS variables, name them well, add fallbacks, and build light and dark themes with prefers-color-scheme and light-dark().
By ColorOverflow Editorial TeamUpdated November 13, 20265 min readIntermediate
CSS custom properties, often called CSS variables, let you name a color once and use it everywhere. Change the value in one place and every button, border and heading that uses it updates. They're also the simplest way to build light and dark themes, because a theme is just a different set of values for the same names.
This guide shows how to define palette colors as variables, how to name them so they survive a redesign, and how to switch themes with prefers-color-scheme, a data-theme attribute and the light-dark() function.
Defining and using color variables
A custom property is any property whose name starts with two dashes. Define your colors on :root so the whole page can see them, and read them back with var().
CSS
:root {
--color-indigo-600: #4f46e5;
--color-gray-900: #111827;
}
.button {
background: var(--color-indigo-600);
color: #ffffff;
}Custom properties inherit like color does, so you can also redefine one on a section, such as a dark footer, and everything inside picks up the new value. They've worked in every major browser since 2017.
Exporting a palette as CSS variables
You don't have to type the values by hand. In the Palette Generator, open the Export dialog and choose CSS variables. It copies the palette as a :root block, numbered in order. The values follow your chosen color format, HEX by default.
CSS
:root {
--color-1: #4F46E5;
--color-2: #818CF8;
--color-3: #F59E0B;
--color-4: #111827;
--color-5: #F3F4F6;
}The Shades and Tints Generator exports a whole scale the same way, but names each variable by its step, such as --color-indigo-500, --color-indigo-400 and --color-indigo-300. Numbered names are fine as a starting point; the next section explains how to turn them into names that describe what each color does.
Palette GeneratorPick a color, apply a harmony rule and refine your palette.Naming: raw colors and semantic roles
Most color systems use two layers of names. Raw names describe the color itself: --color-indigo-600 or --color-gray-100. Semantic names describe its job: --color-text, --color-surface, --color-primary or --color-border. Components only use the semantic names, and the semantic names point at raw ones.
CSS
:root {
/* raw palette */
--color-indigo-600: #4f46e5;
--color-gray-900: #111827;
--color-gray-100: #f3f4f6;
/* semantic roles */
--color-primary: var(--color-indigo-600);
--color-text: var(--color-gray-900);
--color-surface: var(--color-gray-100);
}- Rebranding means changing the raw values, and every role follows.
- Theming means pointing the roles at different raw values, without touching components.
- Avoid names that describe the look in semantic roles, such as --color-blue-button. If the button turns green, the name lies.
- Keep the role list short. A handful of roles for text, surfaces, borders, primary actions and status covers most interfaces.
Our guide to design tokens and color systems takes this idea further, with a third layer for individual components and a way to share the same names between design tools and code.
Read the full guideDesign Tokens and Color Systems: From Palette to CodeFallbacks
var() takes an optional second value, used when the variable isn't defined: var(--color-primary, #4f46e5). That's useful in components that may be dropped into a page without your theme.
Light and dark themes with prefers-color-scheme
The prefers-color-scheme media query tells you whether the visitor's device is set to light or dark. Define the light theme as the default and redefine only the semantic roles inside the dark query. Setting color-scheme as well makes form controls and scrollbars match.
CSS
:root {
color-scheme: light;
--color-bg: #ffffff;
--color-text: #111827;
--color-muted: #4b5563;
--color-primary: #4f46e5;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--color-bg: #111827;
--color-text: #f3f4f6;
--color-muted: #9ca3af;
--color-primary: #818cf8;
}
}Each theme needs its own contrast check. In the light theme, #111827 text on white gets 17.73:1, the muted #4B5563 gets 7.55:1 and the primary #4F46E5 gets 6.28:1. In the dark theme, #F3F4F6 text on #111827 gets 16.11:1, the muted #9CA3AF gets 6.98:1 and the lighter primary #818CF8 gets 5.94:1.
- #FFFFFF
- #111827
- #4B5563
- #4F46E5
Background, text, muted text and primary. Every text role passes 4.5:1 on the background.
- #1F2937
- #F3F4F6
- #9CA3AF
- #818CF8
Shown with the raised surface #1F2937. Here the ratios are 13.33:1, 5.78:1 and 4.92:1, lower than on the #111827 background but still passing.
Letting visitors choose: a data-theme attribute
To offer a theme switch, set an attribute such as data-theme="dark" on the html element with a little JavaScript, and store the choice. Write the dark values for the attribute, and apply the media query only when the visitor hasn't picked light.
CSS
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: #111827;
--color-text: #f3f4f6;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #111827;
--color-text: #f3f4f6;
}Shorter themes with light-dark()
The light-dark() function takes two colors and returns the first in a light color scheme and the second in a dark one. It reads the element's color-scheme, so you set color-scheme: light dark once and each variable holds both values.
CSS
:root {
color-scheme: light dark;
--color-bg: light-dark(#ffffff, #111827);
--color-text: light-dark(#111827, #f3f4f6);
--color-primary: light-dark(#4f46e5, #818cf8);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }As of November 2026, light-dark() has been Baseline since May 2024 and works in current Chrome, Edge, Firefox and Safari (Chrome and Edge 123, Firefox 120 and Safari 17.5 onward). On a browser without it, any property that uses one of these variables falls back to its inherited or initial value, so if you support older browsers, keep the media query version. Declaring a plain color first only helps when light-dark() is used directly in a property such as background, not inside a variable. It only accepts colors, not other values such as images.
Transparent versions of a variable
A common need is the brand color at low alpha for a tinted background. You can't add alpha to a HEX variable by gluing characters on, but modern CSS can derive it.
CSS
.selected {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.focus-ring {
outline: 2px solid rgb(from var(--color-primary) r g b / 50%);
}Remember that a translucent color blends with what's behind it, so check contrast on the blended result, as our guide to alpha and opacity explains. If you want to animate between theme colors, register the variable with @property and syntax: "<color>", which has been Baseline since July 2024; an unregistered variable just jumps from one value to the next.
Read the full guideAlpha, Transparency and Opacity in CSS ColorsCommon mistakes
- Using raw names like --color-indigo-600 directly in components, so a dark theme has to override every component.
- Redefining only the background in dark mode and forgetting borders, muted text and button text.
- Forgetting color-scheme, which leaves bright white inputs and scrollbars on a dark page.
- Checking contrast in the light theme only.
- Expecting the var() fallback to catch a mistyped value.
Variables turn a palette into a system: one place for raw colors, a small set of roles, and a theme that's only a few lines long. Preview your palette in a real layout before you commit the values.
Palette VisualizerSee your palette on real designs, outfits and rooms.