Custom properties
Naming a value once and using it everywhere: the one change that turns a stylesheet into a system — and makes themes possible.
A name for a value
A custom property is a value you name, then use anywhere: :root { --ink: #1f2937; } defines it, and color: var(--ink); uses it. :root is the page’s outermost element, so everything can see what is declared there.
The point is not typing fewer characters. The point is that the shade now has one home. Change --ink once and every heading, border and button that references it changes together — no search and replace, no half-updated page.
The name is part of the design. --ink and --surface say what role a value plays, so the same rule works in a light theme and a dark one; --gray-700 says what the value is today, which is exactly the fact that changes. A name that describes a role can survive a palette redesign, and a name that describes a colour cannot.
Two properties, used everywhere
:root {
--ink: #1f2937;
--accent: #059669;
}
h1 { color: var(--accent); }
p { color: var(--ink); }
.button { background: var(--accent); color: white; }Values that follow their context
Unlike a variable in a programming language, a custom property is inherited by descendants and can be redefined for a subtree. A .dark section can set --ink: #f9fafb for everything inside it, and every rule that uses the name follows without knowing a dark section exists.
That is how theming is done in CSS today: the same rules read the same names, and a different set of values is handed to the page — by a class, a media query like prefers-color-scheme, or a data attribute a script toggles.
A fallback, and a habit
var(--accent, #059669) uses a fallback when the property is missing, which is handy in components that may be dropped into any page. It is also a decent reason to keep the palette small: three or four properties do more for consistency than twenty do for flexibility.
Resolved in the browser, not at build time
A custom property is not a preprocessor variable. It is a real property, stored on an element, inherited by its descendants, and substituted when the browser computes styles. That means it can change after the page loads — a class toggle or a media query can hand a subtree a different value, and every rule reading the name follows immediately.
Because the substitution happens late, the value is not type-checked where it is written. --gap: 1rem is just text until it lands in gap. That flexibility is the point, and it is also why a typo shows up as a missing declaration rather than an error in the stylesheet.
A theme is a different set of values
Theming works by redefining names, not by rewriting rules. Put --ink, --surface and --accent on :root, then set different values on .theme-dark or under @media (prefers-color-scheme: dark). The rules below never learn that a theme exists; they only ask for the names.
Keep the set small and give every name a role. A handful of surface, text, accent and spacing properties covers most pages, and a fallback such as var(--accent, #059669) keeps a component usable when it is dropped into a page that does not define the palette.
Where to declare them
:root is the usual home for a palette because everything inherits from it, but it is not the only one. A component can declare its own properties on its own selector, which keeps a private value out of the global namespace; a wrapper can declare a spacing scale that only its children read. The rule is scope: put a property where the things that need it can see it, and no wider.
There is a cost to a large global palette. Every name on :root is a promise that it will mean the same thing everywhere, and a page with forty properties has forty chances for one of them to be used for something else. Three or four role names — text, surface, accent, maybe a border — cover most pages, and a component that needs a fifth can declare it locally.
Three custom-property mistakes: assuming names are case-insensitive — --Ink and --ink are different properties; using var(--name) with no fallback, so a missing value makes the whole declaration invalid at computed-value time rather than quietly using the previous rule; and expecting a value to fit a shorthand when it carries the wrong parts, since border: var(--line) only works if --line is a complete border value.
Try these next
- Add a
.theme-darksection that redefines--inkand--surface, with a card inside it. - Write a fallback chain:
var(--accent, var(--brand, #059669)). - Turn a spacing scale into
--space-1,--space-2and--space-3, and use them in two components.
Name the colours once
The green is repeated in three rules. Declare two custom properties on the page root — one for the text, one for the accent — and use them instead, so a single change moves the whole page.
2 left to do
- The properties are declared on the page root
- The rules read them with var()
:root { --ink: #1f2937; --accent: #059669; } then color: var(--ink); and background: var(--accent);.
A dark panel inside a light page
Name the page’s text, surface and accent colours on the root, then give the panel its own values so everything inside it turns dark without changing a rule below. The page outside the panel stays light.
3 left to do
- The palette is declared on the root
- The rules read the names with var()
- The panel redefines at least one colour
:root { --ink: #1f2937; --surface: #ffffff; --accent: #059669; }, then .panel { --ink: #f9fafb; --surface: #111827; }; use var(--ink) and var(--surface) in the rules.
Name properties for their role — --ink, --surface, --accent — not for their colour. --dark-green becomes wrong the day the palette changes, and the name is the one thing every rule depends on.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What is a custom property?
