Astro
AppliedField note2 min read

Why your CSS variable indirection silently stops cascading

A var() that points at another var() resolves where it is declared, not where it is used — which quietly breaks theming built on nested overrides.

Learned building The Studio

Written and engineered by

Here is a bug that produces no error, no warning, and no visual clue that anything is wrong. It just makes your theming not work, forever.

The setup

You want components that take their colour from context. A card reads a generic --accent, and an ancestor decides what that means:

:root                  { --accent: silver; }
[data-zone="wpaudio"]   { --accent: #6FE3CC; }
[data-zone="wings"]    { --accent: #F5C27A; }

Then, so utilities can be generated from it, you add one layer of indirection:

:root {
  --color-accent: var(--accent);
}

.text-accent {
  color: var(--color-accent);
}

Now <div data-zone="wpaudio"><p class="text-accent"> renders… silver.

What actually happened

Custom property substitution resolves at the element where the custom property is declared, not where it is eventually consumed.

--color-accent: var(--accent) is declared on :root. So it computes once, against the root’s --accent, and its computed value is the literal string silver. That result is what every descendant inherits.

The [data-zone="wpaudio"] rule fires perfectly. --accent really is #6FE3CC inside that subtree. But --color-accent was already resolved a generation earlier, and nothing re-runs it. You have a variable pointing at a value that has moved on without it.

This is specified behaviour, not a browser bug — and it is easy to miss because the one-hop version, color: var(--accent) used directly, works exactly as you expect.

Three ways out

Remove the indirection. If the utility can reference --accent directly, the problem disappears. This is the right answer whenever you control the utility.

Re-declare the indirection at every scope that overrides it. Correct, but you now have to remember it at each new zone — and forgetting is silent.

[data-zone="wpaudio"] {
  --accent: #6FE3CC;
  --color-accent: var(--accent); /* re-resolve here */
}

In Tailwind v4, use @theme inline. This is the same fix as the first option, delegated to the compiler:

@theme inline {
  --color-accent: var(--accent);
}

inline tells Tailwind to compile the utility down to color: var(--accent) rather than routing through --color-accent. One hop, resolved at the element, cascade intact.

How to catch it

Do not check the source. Check the compiled output, and confirm the utility resolves in one hop:

grep -o "\.text-accent{[^}]*}" dist/_astro/*.css
# want: .text-accent{color:var(--accent)}
# not:  .text-accent{color:var(--color-accent)}

The general rule is worth keeping past this specific case: a custom property whose value is another custom property is a snapshot, not a live link. Any theming system built on nested overrides needs its indirection either removed or re-declared at every scope — and since the failure mode is a colour that is merely wrong rather than missing, it will not announce itself. Assert on the build output instead.