A progressively enhanced, accessible radio button using Web Components and :host(:has(🤯))
Last week I was playing with Firefox’s implementation of the :has() selector (which is finally landing very soon!) and discovered something sort of interesting[1]: you can conditionally apply CSS in a component’s shadow DOM based on what’s been slotted into its light DOM by using :host(:has()). This is quite different from ::slotted(), which lets you style direct light children themselves.

Why is this useful? Consider a form-associated custom element. Worse yet, consider a radio button — a form-associated element that cares what other, similarly-named radio buttons are doing. Making a custom radio button is a pain because you need to deal with all of the usual form-associated stuff like ID references for labels and accessibility and names and values for <form>s, plus you have to find a way to make sure that checking one radio button in a set unchecks the others. If you want to create a fully custom radio button with custom markup in the shadow DOM, it’s no small job. Yet these are all things that a plain old light DOM <input type="radio"> does for free.
There’s been a lot of talk lately about wrapping HTML with Web Components to progressively enhance them. Being able to style the shadow DOM based on a selector matching something in the light DOM opens up the possibility of doing that here by putting the <input> in the light DOM and wrapping it in a web component. Pure progressive enhancement!
<my-radio>
<input type="radio" name="rad-group" value="opt-a" id="opt-a">
</my-radio>
<label for="opt-a">Option A</label>
<my-radio>
<input type="radio" name="rad-group" value="opt-b" id="opt-b">
</my-radio>
<label for="opt-b">Option B</label>
Set aria-hidden="true" on the custom shadow markup, then style it like this using :host(:has()):
:host(:has(:checked)) #nub {
background-color: var(--_input-color);
}
:host(:has(:disabled)) {
--_input-color: var(--_input-disabled-color);
}
:host(:has(:focus-visible)) #outline {
box-shadow: 0 0 0 1.2em rgb(0 0 0 / .2);
}
The only other trick to making this work is making the actual radio button an invisible box over the top of the custom one so that clicking it checks it by doing something like:
::slotted(*) {
appearance: none;
-webkit-appearance: none;
margin: 0;
padding: 0;
z-index: 1;
}
In this example I’m not using particularly interesting custom markup (so it could have been done with pseudo-elements in the slotted radio button itself), but it opens up a world of possibilities.
See the Pen Firefox ≥ 121 only – :host(:has(:checked)) by Noah (@noleli) on CodePen.
Note: this only works in Firefox 121 right now
So there you have a progressively enhanced, accessible radio button with minimal JavaScript in a Web Component, though it’s only reliable in Firefox 121 (beta/dev/nightly) right now. I’m not sure what the spec say about whether this should actually be allowed, so who knows when it might come to other browsers, but the idea is powerful and exciting.
One question that Konnor brought up on Discord is whether the syntax should make the distinction between shadow selectors and look-outside light selectors more explicit with something like
:host(:has(::slotted(:checked))) {}
I understand the argument, but I think of :host() as the CSS equivalent of a Web Component’s this; it’s this element, not this element’s shadow root, so :host(:has()) makes more sense to me.
I’m curious what spec-minded folks think about this technique in general and the syntax in particular!