Shadow Themes

Web Components have found their way into many websites, whether you like it or not. In fact, they're way more prevalent than you might think. I'm a big fan of Web Components libraries. My primary use case for them is Design Systems.
Web Components are, by their very nature, framework-agnostic. This quality renders them exceptionally suitable for seamless reuse. Especially when deployed across diverse teams, products, and IT landscapes. Not to mention that they are fast, reliable, easy to integrate, and native to the Web Platform. Using these benefits across the entire organisation is a big win.
As with many things, it's not all a bed of roses.
Yet, in my view, worth it in the end.

How the sausage is made

Styling Web Components is, sadly, a bit of a mixed bag. So, let's take a look at what works and what doesn't.

CSS features that enhance the value of Web Components are:

  1. Custom Properties (passing through shadow boundaries)
  2. Container Queries (Looking outward)
  3. has() (Looking inward)
  4. Constructable Style Sheets (Reusing styles with appropriate cashing)
  5. Import attributes. Aka Native CSS modules.
/* component.css
   Example:
	 - import styles from './component.css' with { type: 'css' };
	 
	 And inside the custom elements constructor:
	 - this.shadowRoot.adoptedStyleSheet = [styles];
 */

:host {
	--color: var(--design-system-color);
	display: grid;
	container-type: inline-size;
}
div {
	grid-template-columns: 1fr;
	color: var(--color);
}
@container (min-width: 420px) {
	div:has(img) {
		grid-template-columns: 1fr 2fr;
		& * {
			grid-column: 2;
		}
		& img {
			grid-column: 1;
		}
	}
}

Alas, only Custom Properties can be used in a mainstream solution. The others have either not landed in each of the big three browsers or are still too new.

Both Import attributes and Constructable Style Sheets have polyfills. I'm limited to using the latter as Import attributes are still undergoing changes. For example, the assert attribute is now deprecated. Chrome uses this deprecated version, the only version available in any browser. Yet, this deprecation does signal progress towards universal browser availability. Import attributes will elevate Constructable Style Sheets to a primary Style Sheet solution. Web Components profit from this approach. Until then, we will remain stuck with the CSS-as-JS solution. Our approach, using template literals for CSS, is at least not awful. But locking one format inside another is not the way forward.

Design implementation focus

All this can be maddening, so an approach to make this more manageable is essential. You build something by knowing who you will work with and the goals they need to achieve.

  1. Organisation
    • Users (Maintainers/Contributors)
    • End Users (Consumers)
  2. Implementation
    • Frameworks
    • Content systems
  3. Collaboration and distribution
    • Repos
    • Registries

A simple ad campaign website with a short life span for a narrow target audience is one thing. A large complex portal that is (semi) permanent and accessible to everyone is another.
Both need to reuse solutions, so they have room to focus on the unique part that will provide the most benefit.
Reuse what is common and customise what is valuable. Many fall into the trap they think the parts we reuse and customise are the same for any type of website. Or at least don't consider that there is a difference. To make matters worse, UI libraries don't seem to take these concerns into account at all. Is it that they can't or that they won't?

My focus here is on large sites. It's common for me to work on an ecosystem of many (large) sites and applications. We want to reuse the parts used often and prevalent across sites and applications. So we can focus on the parts that make a site unique. For large sites, the reuse part is everything you automate. Each reusable part can be custom-built if needed, like a design system.
Conversely, a one-off marketing site usually has a bespoke design. Creating a design system would be wasteful. The repeated effort is in the setup. You would, for example, look for ways to have a scaffold/bootstrap to get up and running.

Bespoke and reusable Web Components

Web Component shields its content from the document's DOM tree through encapsulation. In other words: You can introduce them to almost any web stack. Their mobility comes from having their own dependency tree and lifecycle.

There is one catch. Styles, by design, leak into Web Components. For example, fonts are not loaded into the shadow dom. Fonts are only globally available via the document. Some values set on the document root pass through the shadow DOM barrier. I've found it helpful to understand the system for the design implementation I aim for.
I tend to structure a style system like this:

  • Resets, defaults, and global/host patterns
  • Content (Text and visual elements)
  • Forms (Interactive elements)
  • Assets (fonts, icons, etc.)
  • Custom Properties (a.k.a. tokens)
  • Page Layout (stacked, three columns, mobile, etc.)
  • Modes (light/dark)
  • Components
    • Light DOM Styles (Themes and general behaviour like FOUC)
    • Shadow DOM Styles (Functional styles and enable Theming)

Style Organisation

One of the new CSS features that I'm embracing is CSS Layers.

/* layers.css - This must be loaded first */
@layer defaults, theme, components;

/* defaults.css */
@layer defaults {
	body {
		color: canvasText;
	}
}

/* theme.css */
@import url('https://unpkg.com/open-props') layer (theme);
@layer theme {
	:root {
		--ds-color-normal: var(--green-7);
		--ds-color-error: var(--red-7);
	}
}
/* cards.css */
@layer components {
	card-component {
		&:not(:defined) {
			visibility: hidden;
		}
		&::part(errortext) {
			--card-color-text: var(--ds-color-error);
		}
	}
}

Also, you can use CSS Layers inside a web component so that it's easier to manage. Not necessary in most situations, but I've found it a clean way to author my styles.

@layer components {
	@layer props, selectors, modifiers;

	@layer props {
		:host {
			--card-gap: var(--ds-space-m);
			--card-text-color: var(--ds-color-text);
			--card-surface-color: var(--ds-color-background-canvas);
		}
	}
	@layer modifiers {
		:host([data-context='promo']) {
			--card-text-color: var(--ds-color-promo);
			--card-surface-color: var(--ds-color-background-promo);
		}
	}
	@layer selectors {
		.card {
			padding: var(--card-gap);
			color: var(--card-text-color);
			background-color: var(--card-surface-color);
		}
	}
}

Be aware even though support is excellent, it's surprisingly stable. You should check if your user's browser can use them.
https://caniuse.com/?search=layers
This feature can be polyfilled, but you may want to hold off because it's still a bit of a faff. Also, CSS Layers can break when you have a typo in the layer name. Any layer undeclared layer gets popped onto the end of the layer stack. Webtools is your friend here, as it can list, in order, the layers defined on the page.

A mistake is easily made, so I check my CSS for wayward layers.

See the Pen CSS Layer check by Egor Kloos (@dutchcelt) on CodePen.

But what about styles for the custom element that sits in the document, the light DOM part?
Well, this is where things get a bit hacky.

Using slots offers amazing flexibility, but they're also easily overridden. So styling them inside a Shadow DOM requires some defensive measures. Also, as slots render before Shadow Dom Content, we must deal with FOUC. Let's look at that first.

Container components with no content can also hide slotted content. The :has() selector allows you to wait until all nested components are ready.

/* Prevent FOUC 
 * This must be loaded before any content starts to render */
layout-component:has(:not(:defined)) {
	visibility: hidden;
}
card-component:not(:defined) {
	visibility: hidden;
}

Doing this on the page level for all components actually speeds up the page rendering!

With Web Components, you can load defensive styles into the global scope.

/* card-component.css
 * document.adoptedStyleSheet = [card-component.css] */

card-component {
	&::part(title) {
		color: var(--ds-color-brand-02);
	}
	& [slot='intro'] {
		font-weight: var(--ds-typography-weight-semibold);
	}
}

A Custom Element selector like this might still be unique(ish). But, its specificity is relatively weak. Generating a unique class attribute on the host element might be enough. But we need something with a bit more teeth, so we don't leave it to chance.

@Scope, where art thou?

Some say that @scope isn't needed as we already have Shadow DOM. But this tenet doesn't hold. It's comparing apples to oranges. One encapsulates styles inside the Shadow DOM. And the other overlays the global scope with some boundaries. I want both, I'm greedy like that.

What would be a huge benefit is if we load both scenarios from above at once. The following example gives us some protection and an interface for customised styling.

@scope (card-component) {
	:scope {
		--card-space-block: var(--ds-space-block);
		--card-space-inline: var(--ds-space-inline);
	}
	&:not(:defined) {
		visibility: hidden;
	}
	&::part(title) {
		color: var(--ds-color-brand-02);
	}
	& [slot='intro'] {
		font-weight: var(--ds-typography-weight-semibold);
	}
}

I haven't read anything to suggest that this feature will make it into all the major browsers. Fingers crossed. If anybody knows, I'd love to hear.
If only browsers could provide a way to beef up the styling of slots. I don't want to reach into the Light DOM to fix things.

Thoughts

As you can see, the recently landed solutions have a lot of potential—also some quirks to deal with. But on the whole, the Styling of Web Components is in a good place. And there is still more to come. I haven't had this much fun with CSS in a long time. Now is a good time to take a look at Web Components and CSS features.

Posted on: August 12th, 2023

Next Post

20 years ago, I had an idea

I started working on a CSS Zen Gargen 20 years ago.

Previous Post

Web Development, the back and front of it

Web Development has become front-end development, and we lost something on the way.

CSS JOY Webring