Have you ever wanted to display all variants of a Button next to each other in your Storybook like your designer did in Figma? But then you come to a screeching halt when you realize :hover, :active, :focus don’t work without user-interaction? Blergh. Whatever can we do?
I got nerd-sniped by this problem in the ShopTalk Discord when Nils said he was trying to build a simple state machine demo for his components. A handful of immediate options came to my head:
- Style states using
.is-hoverutility classes (à la SMACSS) - Create a system of
[data-state="hover"]attributes and copy over styles - Build some over-the-top Playwright-based screenshot contraption
Each of those approaches has trade-offs for me. I don’t want to ship extra component styles or manage duplicate global styles. And if I’m building a Playwright contraption for a CSS demo… yeesh, I should probably back away from the keyboard. When Nils mentioned he was using web components, I realized I might have an ace up my sleeve for a simple’ish solution to this problem.
See the Pen Pseudo-pseudo-states Demo! by Dave Rupert (@davatron5000) on CodePen.
There’s a handful of Advanced Web Component Tips & Tactics happening in this demo like adoptedStyleSheets, ElementInternals, and custom state sets but the approach is pretty lightweight. I scan the custom element’s styles, clone the interactive bits, rewrite styles like :hover state to :state(hover) and add the new stylesheet inside the component.
What I like about this approach compared to the other ideas I had is:
- Styles owned by the component - No “magic” global styles or utilities.
- Appended styles also owned by the component - No global conflicts.
- Interactive states use regular HTML/CSS - Avoid managing interactive state in JavaScript when possible.
- Write once, ship once - I don’t write or ship duplicate
:hoverand.is-hoverstyles to customers. - Low maintenance - Beyond the initial setup, it’s automatic and I’m resilient to component style updates.
Want an end-to-end walkthrough of how it all works? Please, follow me…
Adopting stylesheets in your web components
In web components, we’d typically author our styles in a CSS tagged template literal:
const MyButtonStyles = css`
/* Change the host-element itself */
:host(:hover) {
background: pink;
scale: 1.1;
}
/* Update a child element based on the host-element interaction */
:host(:hover) button {
background: pink;
scale: 1.1;
}
/* Or put the interaction on the interactive child-element */
button:hover {
background: pink;
scale: 1.1;
}
`
Then we create a new “constructed stylesheet” and populate the stylesheet with the styles we wrote. That stylesheet then attaches to our component by adding it to the Shadow Root’s array of adoptedStyleSheets .
/* Create a new constructed stylesheet */
const MyButtonStyleSheet = new CSSStyleSheet()
MyButtonStyleSheet.replaceSync(MyButtonStyles)
class MyButton extends HTMLElement {
constructor() {
/* Setup and create shadowroot */
super();
this.attachShadow({ mode: "open" })
/* Adopt the constructed stylesheet */
this.shadowRoot.adoptedStyleSheets = [MyButtonStyleSheet]
/* ... etc */
}
}
The component will now render its styles without a <link> tag or inline <style> tag in the template. If we put two MyButton components on the page, they’ll share the same stylesheet in memory which is a pretty neat efficiency gain for style application. adoptedStyleSheets aren’t limited to web components, but they’re still pretty niche. But the web component ecosystem’s pattern of using adoptedStyleSheets for style application will come in handy later when we want to apply our new custom states.
Attach internals to unlock custom states
Before we jump to the grand finale, there’s one other unique-to-web-components feature we need to talk about first: ElementInternals. In a web component class you can call attachInternals() which allows you to dip into the element’s guts and mutate the innards of your spicy div. An example of this might be setting an ARIA role:
class MyButton extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals()
this._internals.role = "button"
}
}
customElements.define('my-button', MyButton)
The accessibility inspector will report a role of button for your <my-button> custom element. Setting ARIA role is a weak example because both <my-button role="button"> and Element.role = "button" both work pretty seamlessly. But ElementInternals brings other ✨superpowers✨ like adding form validation behaviors that you can’t do on a normal div. One newer capability of ElementInternals is that it allows you to create your own custom pseudo-classes and apply styles using :state().
class MyScrollingElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals()
}
connectedCallback() {
this.addEventListener('scroll', this.handleScroll)
this.addEventListener('scrollend', this.handleScrollEnd)
}
/* Use arrow functions to avoid `this` complications */
handleScroll = (_event) => {
this._internals.states.add('scrolling')
}
handleScrollEnd = (_event) => {
this._internals.states.delete('scrolling')
}
}
Now you can style your element when it’s scrolling using :state(scrolling) and you’re manipulating the element’s internal state, not modifying HTML in the DOM.
:host {
overflow-y: scroll;
transition: background 0.2s;
}
:host(:state(scrolling)) {
background: pink;
}
Isn’t that cool? With great power comes great spider-mans, of course, but having your own custom pseudos is a thing you can do now with web components.
Mimic native pseudo-states using custom states
Now that we have a loose grasp on all the pieces we need to make our demo work, the next steps are:
- Find all our components and loop through them
- Copy their interactive rules to a new constructed stylesheet
- Replace
:hoverwith:state(hover) - Adopt the constructed stylesheet in the component
- Attach internals and imperatively set the state
Here’s a simplified version of what’s happening in the CodePen version.
/* A bit of pseudo-code here... */
const MyButtons = document.querySelectorAll('my-button');
const sheet = new CSSStyleSheet();
MyButtons.forEach(async (button, index) => {
const isDefined = await customElements.whenDefined(button.localName);
// Grab the stylesheet from the first
if(isDefined && index === 0) {
// Get current CSS rules
const cssRules = Array.from(
button.shadowRoot.adoptedStyleSheets[0].cssRules
)
.filter(rule => rule.cssText.includes(':hover'))
.map(rule => rule.cssText.replaceAll(':hover', ':state(hover)'));
// Update the stylesheet contents
sheet.replaceSync(cssRules.join(''));
}
// Adopt the stylesheet
button.shadowRoot.adoptedStyleSheets = [...button.shadowRoot.adoptedStyleSheets, sheet]
// Attach internals and set the state
if(button.dataset.state == 'hover') {
const _internals = button._internals ?? button.attachInternals();
_internals.states.add('hover');
}
})
And that’s the gist of the trick: clone the pseudo-class states and set imposter custom states. I’m hand-waving some of the more complex bits like button:hover {} needs to be rewritten to :host(:state(hover)) button {}, but again, feel free to read the code.
And my demo makes a couple assumptions I know won’t scale:
- All
<my-button>elements use the same static stylesheet (e.g. it doesn’t change per component). - Each
<my-button>only has one adoptedStyleSheet. - Hard-coded to
HTMLButtonElement-specific interactions. - Assumes you either don’t have
ElementInternalsalready setup or already externally expose them and you know that@publicproperty name.
Next steps might be to make this a bit more robust and support all the other platform pseudo-classes like :popover-open, :user-invalid, or :playing. An agent could probably do this, not me though. I’m tired.
Shadow DOM isn’t that bad, actually.
Shadow DOM often gets a bad rap even in web component circles. I fully understand the friction there. Everyone’s first test drive of Shadow DOM styling almost always ends up in flames, leading to a poor unboxing experience that’s hard to shake off.
That said, I feel like this demo captures a glimpse at some of the unique ✨superpowers✨ you get when styling with Shadow DOM. When coming from a global styles world, that loss of control can feel frustrating. But in my experience, with Shadow DOM encapsulation you’re gaining predictability and precise control by keeping everything local.
You could probably figure out a way to do this with global styles. It’d be more difficult with roboclass-based scoping from something like CSS Modules, but I think it’s possible. But I imagine you’d have to parse through all the global document.styleSheets and that seems like a lot of work to add a :hover state demo to a <button>. Style encapsulation here saves a lot of work.
If you got all the way here and still hate Shadow DOM, I’d love to read a post of yours about where it broke down for you. But if you use Shadow DOM on the regular, I’d love to hear why it’s great. This has been my mantra for awhile now, but I think the web component community can do more about promoting the benefits. For example, there’s only two articles about adoptedStyleSheets on CSS-Tricks which seems low.
In the meantime, while I try to convince my bosses to let me blog for a living, I’ve got my free course guidebook online and you can check it out.