CSS Custom Properties
Declare and use CSS custom properties (CSS variables) to centralize values, build a fallback-aware theming system with var(), and understand how inheritance makes light/dark mode swaps possible with almost no extra code.
Before custom properties, 'theming' a site meant find-and-replace across every stylesheet, or a CSS preprocessor like Sass that only computed variables once, at build time. CSS custom properties are different: they live in the browser itself, can be read and changed at runtime, inherit through the DOM like any other CSS property, and are the exact mechanism behind almost every modern light/dark mode toggle — including EngiLipi's own theme system.
Declaring a custom property
A custom property is any property name that starts with two dashes: --primary-color, --spacing-unit, --max-width. You declare it inside a rule just like any normal property, and it's conventionally declared on :root, the highest-level selector (equivalent to <html>), so its value is available to the entire document through inheritance.
Custom property names are case-sensitive: --Primary-Color and --primary-color are two different properties. Unlike normal CSS properties, the browser does not validate the value's type at declaration time — --spacing: banana; is perfectly legal CSS, it just won't produce anything useful when used.
Reading a value with var()
You read a custom property's value with the var() function: color: var(--primary-color). var() can also take a second argument as a fallback, used only if the custom property is not defined (or is invalid) at the point of use: color: var(--primary-color, #333).
The fallback isn't just for typos — it's genuinely useful for components meant to be dropped into different contexts, some of which may not define every variable. A reusable button component can reference var(--button-radius, 4px) so it still looks reasonable even in a page that never set --button-radius.
Inheritance: the key difference from Sass variables
Custom properties inherit down the DOM tree exactly like color or font-family do. If you declare --theme-color on :root, every descendant element can read it with var(--theme-color) unless something in between re-declares it with a new value — and that's exactly what makes 'scoped' overrides possible.
For example, you can set --link-color: blue on :root, but override it to --link-color: white inside .dark-section, and every link within .dark-section (and its descendants) will automatically pick up the white value, while links elsewhere on the page keep using blue. No JavaScript, no extra classes on each link — just cascading inheritance doing the work.
Building a light/dark theme system
This inheritance behavior is precisely how light/dark mode toggles work in modern CSS. You declare your palette as custom properties on :root for the default (light) theme, then declare a second block — often triggered by a [data-theme="dark"] attribute on <html>, or a prefers-color-scheme media query — that redeclares the SAME variable names with dark-mode values.
Every component in your CSS then just uses var(--background) and var(--text-color) instead of hardcoded colors. Flip the data-theme attribute (usually with one line of JavaScript toggling an attribute), and every single component across the whole site repaints instantly, because they were all reading from the same variable names the whole time.
Declaring and using custom properties
Custom property names must start with -- and are case-sensitive var() takes the property name first, then an optional fallback value second Redeclaring a variable inside a more specific selector overrides it for that subtree only, thanks to inheritance
:root {
--primary-color: #3366ff;
--background: #ffffff;
--text-color: #1a1a1a;
--spacing-unit: 8px;
}
.card {
background: var(--background);
color: var(--text-color);
padding: calc(var(--spacing-unit) * 2);
}
.button {
/* fallback used if --button-radius is not defined */
border-radius: var(--button-radius, 4px);
}
/* dark theme override: same variable names, new values */
[data-theme="dark"] {
--background: #121212;
--text-color: #f0f0f0;
}Code Examples
Every component in this stylesheet reads --bg, --fg, and --accent instead of hardcoded colors. Toggling data-theme="dark" on the <html> element (with one line of JavaScript: document.documentElement.setAttribute) instantly re-themes body background, text, and links everywhere on the page, because [data-theme="dark"] redeclares those three variables with new values that the whole document inherits.
[data-theme="dark"] { --bg: #0f0f0f; --fg: #eaeaea; --accent: #6699ff; }
This badge component can be dropped into any page. If that page's stylesheet never defines --badge-bg, --badge-color, or --badge-radius, the fallback values kick in and the badge still looks reasonable instead of breaking or rendering unstyled.
Links anywhere on the page are blue by default. But because .promo-banner redeclares --link-color to white, and custom properties inherit, every link nested inside .promo-banner automatically becomes white — no separate .promo-banner a { color: white } rule needed.
:root {
--bg: #ffffff;
--fg: #111111;
--accent: #3366ff;
}
[data-theme="dark"] {
--bg: #0f0f0f;
--fg: #eaeaea;
--accent: #6699ff;
}
body {
background: var(--bg);
color: var(--fg);
transition: background 0.2s, color 0.2s;
}
a {
color: var(--accent);
}.badge {
background: var(--badge-bg, #eeeeee);
color: var(--badge-color, #333333);
border-radius: var(--badge-radius, 999px);
padding: 0.25rem 0.75rem;
}:root {
--link-color: #1a56db;
}
.promo-banner {
--link-color: #ffffff;
background: #111827;
}
a {
color: var(--link-color);
}Custom property inheritance
Custom properties declared on :root flow down through every descendant via normal CSS inheritance, and any element in the tree can read them with var().
:root
+-- --primary-color: #3366ff
+-- --background: #ffffff
+-- --text-color: #1a1a1a
|
| (inherits down the DOM)
v
<body>
|
+-----+------+
| |
<header> <main>
| |
var(--bg) var(--text-color)
used usedImportant Rules
Custom property names must begin with two dashes (--) and are case-sensitive.
Custom properties inherit through the DOM like color and font-family — an element without its own declaration uses its nearest ancestor's value.
Redeclaring the same variable name inside a more specific selector overrides it only for that selector's subtree, leaving the rest of the page unaffected.
Unlike Sass/Less variables, CSS custom properties exist at runtime and can be read and changed by JavaScript via element.style.setProperty().
This is EngiLipi's own theme system
EngiLipi's site-wide light/dark toggle works exactly like the example above: a palette of custom properties declared on:root, a [data-theme="dark"] block that redeclares the same variable names with dark values, and every component — nav bar, code editor, lesson cards, quiz panels — reading colors exclusively through var(). That's why toggling the theme repaints the entire site instantly with zero per-component JavaScript..card { background: --background; }.card { background: var(--background); }
A custom property's value can only be read through the var() function. Writing --background directly as a value is invalid CSS — it doesn't 'unwrap' automatically.:root { --Primary-Color: blue; }.btn { color: var(--primary-color); }:root { --primary-color: blue; }.btn { color: var(--primary-color); }
Custom property names are case-sensitive. --Primary-Color and --primary-color are two completely different properties, so var(--primary-color) here would be undefined and silently fall back (or fail) rather than resolving to blue.
[Identify the Error] A developer wants links inside .sidebar to be gray while every other link on the page stays blue. Why doesn't this CSS achieve that? :root { --link-color: blue; } a { color: var(--link-color); } .sidebar { --Link-Color: gray; } A. Custom properties can't be redeclared inside a class selector, only on :root B. The .sidebar rule uses --Link-Color (capitalized) but the a rule reads var(--link-color) (lowercase) — custom property names are case-sensitive, so they don't match C. var() cannot be used inside an a selector D. The :root declaration must come after the .sidebar declaration in the file for the override to work
B. The .sidebar rule uses --Link-Color (capitalized) but the a rule reads var(--link-color) (lowercase) — custom property names are case-sensitive, so they don't match — Custom property names are case-sensitive. --Link-Color and --link-color are two entirely different properties, so the override inside .sidebar never actually affects what var(--link-color) resolves to — links inside .sidebar stay blue instead of turning gray.
Create a:root block with --spacing-unit: 8px and --border-radius: 4px. Then write a.panel rule that uses padding: calc(var(--spacing-unit) * 3) and border-radius: var(--border-radius, 8px).:root { --spacing-unit: 8px; --border-radius: 4px; }.panel { padding: calc(var(--spacing-unit) * 3); border-radius: var(--border-radius, 8px); }
You can combine var() with calc() — calc() will read the variable's value and do math with it just like a literal number.