---
title: 'Custom Properties'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/custom-properties'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:29+00:00'
license: 'CC BY-SA 4.0'
---

# Custom Properties

Custom properties being computed at the element they are declared on, inheritance carrying the computed value, fallback value chains, invalidity at computed-value time, and building a component's style interface.

Every rule in the previous lesson's component file refers to a name: `var(--line)`,
`var(--warning)`, `var(--spacing-1)`. These names were defined on the root element in the
Visual Presentation with CSS course, and that was enough for that course.

Component architecture wants more. A card variant should change only the surface color; the
aside section should carry its own color scale; a component should announce which values can
be adjusted from outside. This lesson looks at custom properties as an architectural tool, and
first clarifies the moment they are computed.

## The Moment of Computation

A **custom property** is a name starting with two hyphens and read with `var()`. Its deciding
rule is that its value is computed **at the element it is declared on**.

This means inheritance carries not the raw text but the **computed value**. When
`--card-surface: var(--surface)` is written on the root element, the root's `--surface` value
is substituted in, and what travels down the subtree is now that color. Even if `--surface` is
redefined on a descendant element, the `--card-surface` already computed at the root does not
change.

The program below resolves this over a tree.

```js
// custom-properties.mjs — the moment custom properties are computed, inheritance, fallback values, and invalidation behavior
// Rule: a custom property's value is computed at the element WHERE IT IS DECLARED; the
// computed value is inherited by the subtree. Redefining a source property further down the
// tree does not retroactively affect a derived value already computed further up.
const TREE = {
  name: ":root",
  declarations: { "--surface": "hsl(203 18% 97%)", "--text": "hsl(211 29% 16%)",
              "--card-surface": "var(--surface)", "--spacing-1": "1.5rem" },
  children: [{
    name: ".sidebar",
    declarations: { "--surface": "hsl(203 18% 92%)" },            // only --surface redefined
    children: [{
      name: ".measurement-card",
      declarations: {},
      children: [
        { name: ".measurement-card--warning", declarations: { "--card-surface": "hsl(0 66% 96%)" }, children: [] },
        { name: ".measurement-card--plain",  declarations: { "--card-surface": "invalid-value" },   children: [] },
        { name: ".measurement-card--local", declarations: { "--card-surface": "var(--surface)" },   children: [] },
      ],
    }],
  }],
};

const VAR_PATTERN = /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/;

// resolve var() references inside text; lookup(name) returns a custom property's value
function resolve(text, lookup) {
  let s = text, step = 0;
  while (VAR_PATTERN.test(s)) {
    if (++step > 50) return { value: null, reason: "cycle" };
    const [whole, name, fallback] = s.match(VAR_PATTERN);
    const found = lookup(name);
    if (found && found.reason) return found;                       // cycle propagates upward
    if (!found || found.value === null) {
      if (fallback === undefined) return { value: null, reason: `${name} undefined, no fallback` };
      s = s.replace(whole, fallback);
    } else {
      s = s.replace(whole, found.value);
    }
  }
  return { value: s.trim(), reason: null };
}

// an element's custom property map: the inherited map + its own declarations computed
function propertyMap(node, inherited) {
  const result = { ...inherited };
  const resolving = new Set();
  const lookup = (name) => {
    if (name in node.declarations) {
      if (resolving.has(name)) return { value: null, reason: "cycle" };
      resolving.add(name);
      const r = resolve(node.declarations[name], lookup);
      resolving.delete(name);
      return r;
    }
    return name in inherited ? { value: inherited[name], reason: null } : null;
  };
  for (const name of Object.keys(node.declarations)) {
    const r = lookup(name);
    result[name] = r.value;                                   // stays null if invalid
  }
  return result;
}

const VALID_COLOR = /^(hsl\(|rgb\(|#)/;
function walk(node, inherited = {}, path = []) {
  const props = propertyMap(node, inherited);
  const name = [...path, node.name];
  const r = resolve("var(--card-surface, var(--surface))", (n) => (n in props ? { value: props[n], reason: null } : null));
  let outcome;
  if (r.value === null) outcome = `INVALID (${r.reason})`;
  else if (!VALID_COLOR.test(r.value)) outcome = `"${r.value}" -> invalid at computed-value time`;
  else outcome = r.value;
  console.log("  " + name.join(" > ").padEnd(66) + outcome);
  for (const child of node.children) walk(child, props, name);
}

console.log("--- in the tree, background-color: var(--card-surface, var(--surface)) ---");
walk(TREE);

console.log("\n--- fallback value chains at the root element ---");
const ROOT = propertyMap(TREE, {});
const rootLookup = (n) => (n in ROOT ? { value: ROOT[n], reason: null } : null);
for (const expr of [
  "var(--surface)",
  "var(--missing)",
  "var(--missing, var(--surface))",
  "var(--missing, var(--also-missing, hsl(0 0% 100%)))",
  "calc(var(--spacing-1) * 2)",
]) {
  const r = resolve(expr, rootLookup);
  console.log(`  ${expr.padEnd(53)} -> ${r.value === null ? "INVALID: " + r.reason : r.value}`);
}

console.log("\n--- cycle ---");
const D = { name: ":root", declarations: { "--a": "var(--b)", "--b": "var(--a)" }, children: [] };
console.log("  --a: var(--b); --b: var(--a);  -> --a value:", propertyMap(D, {})["--a"]);
```

```
--- in the tree, background-color: var(--card-surface, var(--surface)) ---
  :root                                                             hsl(203 18% 97%)
  :root > .sidebar                                                  hsl(203 18% 97%)
  :root > .sidebar > .measurement-card                              hsl(203 18% 97%)
  :root > .sidebar > .measurement-card > .measurement-card--warning hsl(0 66% 96%)
  :root > .sidebar > .measurement-card > .measurement-card--plain   "invalid-value" -> invalid at computed-value time
  :root > .sidebar > .measurement-card > .measurement-card--local   hsl(203 18% 92%)

--- fallback value chains at the root element ---
  var(--surface)                                        -> hsl(203 18% 97%)
  var(--missing)                                        -> INVALID: --missing undefined, no fallback
  var(--missing, var(--surface))                        -> hsl(203 18% 97%)
  var(--missing, var(--also-missing, hsl(0 0% 100%)))   -> hsl(0 0% 100%)
  calc(var(--spacing-1) * 2)                            -> calc(1.5rem * 2)

--- cycle ---
  --a: var(--b); --b: var(--a);  -> --a value: null
```

The second row is the lesson's most commonly missed point. The `.sidebar` element changes the
`--surface` value, but its surface stays `hsl(203 18% 97%)` — because the `--card-surface` it
reads was already computed on the root element.

The last row shows the fix: once `--card-surface` is redeclared in the subtree, `var(--surface)`
is resolved at that element and comes out `hsl(203 18% 92%)`. The rule is this: if a derived
value needs to be affected by context, the derived value must be redeclared in that context.

The `calc(var(--spacing-1) * 2)` line shows one more detail: substitution happens only at the
text level. Whether the value makes sense is tested only after substitution finishes.

## Fallback Value and Invalidity

`var()` takes a second argument: the **fallback value** to use when the name is undefined.
Fallback values can be nested, and the output's second block resolves a two-level chain.

There is one case where the fallback does not kick in, and that is where the trap lies. If the
name **is defined** but its value makes no sense where it is used, the fallback does not run.
The `--card-surface: invalid-value` declaration is defined; `background-color` takes this
value and becomes invalid at that point.

This condition is called **invalid at computed-value time**, and its result differs from
invalidity during parsing:

- A declaration invalid during parsing is **discarded**; a previous declaration writing to the
  same property stays in place.
- A declaration invalid at computed-value time is not discarded; it puts the property into the
  `unset` state. That is, the inherited value applies for inherited properties, and the
  initial value for non-inherited ones.

Because the previous declaration does not stay in place, the result is usually unexpected.
When a color declaration falls because of a wrong custom property, the element's color comes
from its parent instead.

A cycle is a separate case: two custom properties referring to each other produce no value at
all, and both are treated as undefined.

## Registered Properties

The `@property` rule **registers** a custom property: it declares its value's syntax, whether
it is inherited, and its initial value.

```css
@property --card-surface {
  syntax: "<color>";
  inherits: true;
  initial-value: hsl(203 18% 97%);
}
```

Registration has three consequences. The first is type checking: a value that does not match
the declared syntax falls back to the registration's initial value, and no invalidity at
computed-value time occurs. The second is that inheritance becomes controllable; a property
written with `inherits: false` is valid only on the element it is declared on. The third
concerns motion: an unregistered custom property is only text and its intermediate value
cannot be computed, while a registered color or length can take part in a transition or
keyframes.

The third consequence connects with the motion declarations in the previous topic: a color
scale itself becomes something that can be animated.

## A Component's Style Interface

The rules so far arrive at an architectural pattern. A component defines the adjustment points
it exposes to the outside as custom properties; internally it reads only these names.

```css
.measurement-card {
  --card-surface: var(--surface);
  --card-border: var(--line);
  --card-lift-distance: -8px;

  background-color: var(--card-surface);
  border: 1px solid var(--card-border);
}

.measurement-card:hover { transform: translateY(var(--card-lift-distance)); }

.measurement-card--warning { --card-surface: hsl(0 66% 96%); --card-border: var(--warning); }
.measurement-card--still { --card-lift-distance: 0px; }
```

Modifiers now **give a value** instead of overriding a declaration. The
`.measurement-card--warning` rule does not write `background-color`; it only changes the name
the component reads. This has three consequences: no specificity contest arises, the rule
count does not grow as the number of variants grows, and what the component allows to be
adjusted can be read from the first lines of the file.

The name prefix is also a contract: names starting with `--card-` are the component's
interface, unprefixed names like `--surface` come from the page scale. If the two get mixed
up, what the component expects from outside becomes unclear.

## Theme Management

Theming is filling the same interface with different values. A two-layer scale separates
this: names holding raw values, and names naming a function. A theme changes only the second
layer.

```css
:root {
  --gray-95: hsl(203 18% 97%);
  --gray-15: hsl(211 29% 16%);

  --surface: var(--gray-95);
  --text: var(--gray-15);
}

[data-theme="dark"] {
  --surface: var(--gray-15);
  --text: var(--gray-95);
}
```

Because the theme is declared with an attribute, it can also be applied at the subtree scale:
the whole page can be light while a single section is dark. Inheritance provides this on its
own; no extra rule is needed.

The same structure can also be driven by a media query — the user's color scheme preference
writes the same two declarations inside a query instead of an attribute. If both sources are
used together, which one takes priority is a decision: the user's choice made within the page
usually overrides the system preference, because it is written later and more specifically.

## Summary

- A custom property is computed at the element it is declared on; the computed value, not the
  raw text, is inherited by the subtree.
- For a derived value to be affected by context, it must be redeclared in that context;
  changing the source name in the subtree does not change a derivative already computed
  further up.
- The fallback value runs only when the name is **undefined**; a defined but nonsensical
  value produces invalidity at computed-value time and puts the property into the `unset`
  state.
- An `@property` registration declares syntax, inheritance, and an initial value; registered
  properties gain type checking and can take part in transitions.
- Once a component exposes its adjustment points as prefixed custom properties, variants give
  a value instead of overriding a declaration; specificity stays flat and the interface
  becomes readable.

## Next Step

Every name here is resolved in the browser, at runtime. A separate class of tools does part of
the same work before the document ever reaches the browser: flattening nested notation,
keeping repeated blocks in one place, merging files. The next lesson takes up style tools that
run at compile time, and separates which work should be left to runtime.
