Skip to content
academia.sh

Lesson 19 / 23

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.

Contents

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.

// 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.

@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.

.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.

: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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close