Stuart Robson’s post Solving CSS @layer Ordering in Design Systems with Design Token Metadata, a response to Chris Coyier’s Thinking Horizontally in CSS @layer, came to me at a serendipitous time. You know I love a good blog-and-response, but these posts on the topic of lowering the specificity of component-level tokens and scaling that out was a problem I was having with some of my web components.

Their @layer trick didn’t work for me, but here’s what did…

The local component variable problem

Let’s say we have a global stylesheet with our design system tokens.

/* theme.css */
:root {
	--system-bg: #00f1;
	--system-fg: #000;
	--system-font: Inter; sans-serif;
}

And then we set up our web component with some local component variables that use design system tokens as defaults.

<ui-card>
  <template shadowrootmode="open">
	  <style>
	  :host {
			/* Local component variables */
			--card-bg: var(--system-bg);
			--card-fg: var(--system-fg);
			--card-font: var(--system-font);

			/* Apply component styles */
		  background: var(--card-bg);
		  color: var(--card-fg);
		  font-family: var(--card-font);

			button {
				font-family: var(--card-font);
			}
	  }
	  </style>
	  <slot></slot>
  </template>
  
  Hello world
</ui-card>

This works great… Except… I sort of expected that if we added some of those component tokens to the stylesheet we could restyle the card.

/* theme.css - This doesn't work 😭 */
:root {
	/* ...system tokens go here... */

	--card-bg: #f001;
	--card-fg: #f00;
	--card-font: fantasy;
}

Because :host has more specificity than :root, that means :host wins. This confused even me, a web component front-end master. But Bramus from the Chrome team explained it to me as: If you redeclared the variables in a normal div, it wouldn’t be surprising that the values didn’t inherit. Ok. That tracks.

The canonical way to solve this –of course– is to pull out a bazooka and point it directly at the element:

/* theme.css - Works, but... */
ui-card {
	--card-font: fantasy;
}

This works and I often recommend direct styling the :host as the primary way to customize web components from the outside.

But a system is different than laser-guided fixes. Themes should be low-specificity by design. Using an HTMLElement as a selector is one of the strongest selectors in our CSS toolbox. That means we broke our one rule and if we ever want to change our mind, we’re on a one-way train to !important town. But –to me– the larger problem is that now our CSS and DOM are super-glued together, which is less ideal compared to a flat list of element-agnostic tokens.

What if other card-like elements use those tokens? Uh-oh, gotta update the global stylesheet. What if elements in the Light DOM also want to use those tokens? More work. We need a “Set it and forget it” way to handle themes. Also pinning tokens to the custom-element might not always work in nested component scenarios, but that’s a secondary issue.

There’s a handful of other approaches (see below) to this problem and they all have trade-offs. I was hoping the @layer solution would help, but @layer { :host {} } inside a shadowRoot is still more powerful than :root. Drat!

But is there a low-specificity way to win the specificity war with :host?

A refined solution in :defined

After throwing every scoping method I could at the wall, I found a decent one-liner that allows us to preserve the inheritance in a way that’s web-component’y:

/* theme.css */
:root {
	--system-bg: #00f1;
	--system-fg: #000;
	--system-font: Inter; sans-serif;
}

:defined {
  --card-bg: #f001;
  --card-fg: #f00;
  --card-font: fantasy;
}

When you put <ui-card> on the page, it’s an HTMLUnknownElement. Because HTML is chill, it treats the unknown element as a <span> with a gigantic typo. The :defined pseudo-state triggers on customElement.define() and with that small change we’re able to pierce the shadow boundary and win the specificity war because :defined is more powerful than :host.

But… You know what else gets :defined? All native HTML elements! div:defined, video:defined, and every native element your browser supports also works with :defined.

That sneaky fact gives us some portability that the ui-card selector didn’t. If we need to re-use these tokens inside a Light DOM CSS class, we can also support that with :defined.

.ad-unit-block {
  /* Local component variables */
  --card-bg: var(--system-bg);
  --card-fg: var(--system-fg);
  --card-font: var(--system-font);
  
  /* Apply component styles */
  background: var(--card-bg);
  color: var(--card-fg);
  font-family: var(--card-font);
}

And that’s pretty handy if your default styles and system overrides need to go more places.

Full walkthru demo

See the Pen shadow-piercing local component variables by Dave Rupert (@davatron5000) on CodePen.

One small quirk :not(:defined) elements

As Chris and I discovered in a live recording of ShopTalk, you can run into a weird quirk hanging tokens off :defined if you have strong opinions about :not(:defined) custom-elements inheriting styles.

If an undefined custom-element has a :defined parent, variables from the parent will pierce the shadow boundary. There’s two ways that I found to fix this…

/* The not-has-not route */
:defined:not(:has(:not(:defined))) {
	--card-font: fantasy
}

/* The unset if has-not route */ 
:defined {
	--card-font: fantasy;
}

:has(:not(:defined)) {
	--card-font: unset;
}

I don’t love either of those approaches, so I probably won’t optimize for the “I put a fake undefined element on the page that uses our CSS variables but it must not inherit our CSS variables!” edge case.

Bramus told me about one potential future option that might help this exact situation, the already spec’d out was the inherit() function.

:host {
	--card-bg: inherit(--card-bg, var(--system-bg));
	background: var(--card-bg);
}

I like that. That could work. Let’s do that? Ok. We all agree. Ship it.

Either way, that’s the end of my post. If you’re curious about other ways to handle component-level variables, keep reading!


Alternatives considered

Below are a handful of different workarounds for local-component variables, each with trade-offs.

Make all root overrides !important

/* theme.css - Works but, too heavy-handed */
:root {
	--card-font: fantasy!important;
}

This works, but should be the nuclear option for specificity battles. Also makes it more difficult to override later.

Shipping all variables in the theme

/* theme.css */
:root {
	/* system theme */
	--system-bg: #00f1;
	--system-fg: #000;
	--system-font: Inter, sans-serif;

	/* custom overrides */
	--card-bg: #f001;
	--card-fg: var(--system-fg);
	--card-font: fantasy;
}

This works too. You’ll need to change your <ui-card> styles to directly access background: var(--card-bg). The problem here is that all variables are mandatory and must ship in the theme. Even if a product only uses or customizes just one component, they have to ship all the tokens for every component. And now your theme CSS will be ever-growing at the rate of num_options * num_components.

Optional variable fallback chains in shadowRoot

/* theme.css - Works but, can be confusing to CSS learners */
:host {
	font-family: var(--card-font, var(--system-font));
}

I’ve been down this path. It works well and the first variable in the fallback chain becomes totally optional, meaning we can ship less tokens in the production theme. Fallback chains (a ternary, basically) and Aliases (a DOM walker) have dramatically different performance profiles when it comes to style calculation costs, so you may want to consider this… but that’s for another post.

In practice though, I find the beauty of the CSS cascade gets lost on a lot of six-figure engineers (waves generally in the direction of Hacker News and popular CSS frameworks).

And this also works less good if you’re going to re-use a local variable token… which leads us to…

Private variables with fallback chains in shadowRoot

/* theme.css - Work but, too noisy */
:host {
	--_card-font: var(--card-font, var(--system-font));
	font-family: var(--_card-font);
	
	button {
		font-family: var(--_card-font);
	}
}

This is the fully exploded version of the previous pattern. We can re-use --_card-font on other elements inside our component. You can do this but it begins explode the surface area of the number of tokens we’re managing inside a component. Fallbacks were confusing enough, now we’re doing fallbacks with aliases. Uf.