Special CSS Just For Components

How to create standard components that are less brittle

by Joe Honton

Two facets of web components appear to be in opposition with each other: isolation and customization. How can developers keep internal CSS rules isolated from the outside world? And how can consumers style components to achieve the branding they desire?

This conflict can be overcome with special CSS features designed to target web components.

To begin, let's define the goals for isolation and customization.

Isolation is the firewall created by the component's shadow DOM. With isolation, CSS rules applied to the website only affect elements from the document's root down to the custom element, but not further. On the other hand, CSS rules declared within the component only affect elements descending from its disjunct shadow root.

This is a good thing. It allows both component developers and component consumers to independently declare rules without worrying that their decisions will be unexpectedly overridden.

Customization, on the other hand, is the consumer's ability to tailor the appearance of the component. Well-designed components can be decorated by consumers using CSS, without forking the code.

The CSS Working Group has been experimenting with solutions to this apparent conflict for a few years. The solutions they've adopted are now available in browsers.

Here's what's available and ready for use today:

  • CSS custom properties, for passing values into the component.
  • The :host selector, for self-referential component targeting.
  • The :host() pseudo-class selector, for selecting instances of a component.
  • The ::slotted() pseudo-element selector, for targeting elements "slotted-into" a component by the consumer.

Everything here is standards-based, adopted by the World Wide Web Consortium (W3C), and independent of any proprietary framework, such as React, Vue, or Angular. It's future-proofed.


CSS custom properties

CSS custom properties are employed in the solution to many different problems. They have been around for a while and are familiar to most CSS authors.

As a review, custom properties are often declared at the root level of the document, and used within selectors like in this example:

:root {
--color: #DDD;
--background: #333;
}
p {
color: var(--color);
background-color: var(--background);
}

The :root pseudo-class selector used here may be unfamiliar to some. It is synonymous with the html selector, but with higher specificity. Either one could be used.

Custom properties do not have to be declared at the root. They can also be declared on an element. For example, we might localize the scope of the variables used within a custom element named rwt-shadowbox by doing this:

rwt-shadowbox {
--color: #DDD;
--background: #333;
--alt-color: #EEE;
--alt-background: #222;
}

Custom properties inside components

Now let's move away from the document's CSS and look at the component's internal CSS.

Whenever there's a desire to expose a feature to the outside world, the component's internal style sheet can be designed to use custom property names. And those names can be declared either in the document's style sheet (as shown previously) or inside the component itself.

So perhaps the component's internal style sheet is designed to use those names something like this:

header {
color: var(--color);
background: var(--background);
}
footer {
color: var(--alt-color);
background: var(--alt-background);
}

Unlike fixed values (such as #DDD and #333), custom properties like this pierce the shadow DOM firewall. This is the principal way for component creators to give consumers control over its styling.

If you learn nothing else from this article, remember this: CSS custom properties, defined by the consumer, in the document's style sheet, pierce the shadow DOM firewall.

Custom properties alone are a good start, but we can make our components more flexible with the :host selector.


The :host selector

Good component design will defer final decisions regarding size, position and decorations to the consumer. This can be done through the use of variables, as just demonstrated. Nevertheless, components should not force the consumer to supply these. Reasonable defaults should always be provided by the component creator.

To do this, default variable values are specified inside the component by declaring them within a :host selector. This special selector is shadow DOM's equivalent to document DOM's :root selector.

Consider again the example component called rwt-shadowbox. It is a dialog box which has been designed with customization in mind. The component creator has purposefully used variables for the component's size and position, to give the consumer flexibility in its usage.

To accomplish this, the component creator declared variables within the component's :host selector and used them in its #shadowbox selector, like this:

:host {
--width: 70vw;
--height: 50vh;
--bottom: 1rem;
--left: 1rem;
}
#shadowbox {
width: var(--width);
height: var(--height);
bottom: var(--bottom);
left: var(--left);
}

If the creator had not declared default values for the variables in :host, the consumer would have been forced to provide values for each of the four variables.

The CSS var() syntax also provides for fallback values, as a sort of safety net, for these types of situations. Fallback values are specified as the second parameter to var() like this:

#shadowbox {
width: var(--width, 70vw);
height: var(--height, 50vh);
bottom: var(--bottom, 1rem);
left: var(--left, 1rem);
}

Selecting component instances

Sometimes a single component is purposefully designed to have more than one "flavor". For example, a component might have both a dark mode and a bright mode set of color defaults. In this scenario, the creator might want to simplify the consumer's work by having them specify a class name rather than a series of color variables.

The creator can do this with the :host() pseudo-class selector. Here's what it might look like:

:host(.darkmode) {
--color: #DDD;
--background: #333;
}
:host(.brightmode) {
--color: #222;
--background: #EEE;
}

A consumer choosing to use the dark mode theme would place the component into the document like this:

<body>
<h1>Dark mode</h1>
<rwt-shadowbox class='darkmode'></rwt-shadowbox>
</body>

This technique might also be used to target a specific instance of a component when there is more than one in a document. Perhaps there's one dialog box for members and a slightly different variant for non-members. The component's CSS for targeting the consumer instance identified as non-member would be:

:host(#non-member) {
--color: #DDD;
--background: #333;
}

The consumer using the component in the non-member role would specify the document as:

<body>
<h1>Non member</h1>
<rwt-shadowbox id='non-member'></rwt-shadowbox>
</body>

Selecting slotted elements

Sometimes a component is purposefully designed to allow the consumer to "slot in" elements. I discussed this in detail here.

Consider this usage by the consumer, with three slotted elements:

<body>
<rwt-shadowbox>
<h1 slot='inner' id='caption'>Slotted Header</h2>
<p slot='inner' class='first-para'>First paragraph...</p>
<p slot='inner'>Next paragraph...</p>
</rwt-shadowbox>
</body>

Slotted elements can be targeted using the ::slotted() pseudo-element. It takes one argument, which is a selector such as a tag name, identifier or class name.

The component's internal styling for the example might look something like this:

::slotted(#caption) {
font-size: 2rem;
}
::slotted(p) {
text-indent: 0;
}
::slotted(.first-para) {
text-indent: 1rem;
}

Here, the component creator has used the ::slotted() pseudo-element in three ways: targeting the caption with an identifier, targeting all paragraphs with a <p> tag, and targeting just the first paragraph with a class name.


Summary

All of this is a lot to absorb, so I've open-sourced several custom components where you can study this topic in greater detail here.

In summary, component creators have four techniques at their disposal to give consumers the flexibility they crave:

  • Use CSS custom properties to expose individual settings to the consumer.
  • Use the :host selector inside components, the same way you use the :root selector in documents.
  • Use the :host() pseudo-class selector to give the consumer the ability to target internal component classes.
  • Use the ::slotted() pseudo-element selector to style arbitrary elements slotted-in by the consumer.

With proper forethought, component designs can enjoy the safety benefits of isolation, while providing the flexibility benefits of customization, making things less brittle and more useful.

Special CSS Just For Components — How to create standard components that are less brittle

🔎