Named slots are one of web components’ biggest superpowers ✨. Imagine a Button component with an optional icon; in Web Components we don’t need a separate Button and IconButton, a single Button component with <slot name="icon"> will do. Or a card component with a handful of predefined slots for image, title, description, price, metadata, etc. Not 100 different cards… one card.
Slots are a wonderful way to declaratively compose UI, but one shadow styling challenge that has vexed my team over the years is how to contextually hide a <slot> when there’s no slotted content.
The problem… ghost gaps
The most idiomatic way to check if a particular slot is in-use is to use :host(:has([slot="foo"])). But… In a weird twist to how web component browser-compat bugs tend to go, :host(:has([slot])) works in Safari and Firefox today but doesn’t work in Chromium!
Chromium not supporting :host(:has([slot])) means we can’t query an element’s Light DOM contents from the Shadow DOM. That interpretation of encapsulation makes sense (I guess) and in most situations it’s not a big problem; an empty slot is like an empty span and probably won’t impact rendering. But it becomes a problem when the empty slots appear in a flex or grid layout using gap because unless you hide the element and/or its wrapper, the gap will not collapse in Chromium, so you end up with a “ghost gap”.
Example: With slot="optional" content in Light DOM
<optional-slot-demo-broken>
<div slot="required">Required content</div>
<div slot="optional">Optional content - expected: show content with gap</div>
</optional-slot-demo-broken>
Example: Without slot="optional" content in Light DOM
<optional-slot-demo-broken>
<div slot="required">Required content</div>
<!-- expected: does not show extra whitespace -->
</optional-slot-demo-broken>
- ✅ Works in Safari = wrapper div hidden, no gap
- ✅ Works in Firefox = wrapper div hidden, no gap
- ❌ Broken in Chromium = extra whitespace below
slot="required"
The failed attempts…
We’ve tried a lot of fixes over the last couple years. Variations on :host(:has()), element-name:has(), !important, ::slotted([slot]) ~ [slot], ::slotted([slot]:empty), and all permutations of those. Copilot/Claude were certain these would work… but alas…
The fix… use @scope
This week my co-worker Jeff and I were navigating this issue again. Coincidentally, the ShopTalkShow Discord was discussing the same exact problem and cited the HasSlotController in WebAwesome (née Shoelace) as a working solution to this problem. I’d prefer to keep the solution in pure CSS, but was getting desperate.
While glancing through the code I noticed the use of :scope in L38-40 and while unrelated it sparked a curiosity to try to use :scope to juke Chromium’s shadow limitations. A handful of tries later and I had a working solution.
Here’s what I came up with:
:host {
display: grid;
gap: 1rem;
}
/* Hide optional slots by default */
.optional-slot {
display: none;
}
/* Show in Safari/Firefox */
:host(:has([slot="optional"])) .optional-slot {
display: block;
}
/* Fix for Chromium */
@scope {
:scope:has([slot="optional"]) .optional-slot {
display: block;
}
}
Example: With slot="optional" content in Light DOM
<optional-slot-demo>
<div slot="required">Required content</div>
<div slot="optional">Optional content - expected: show content with gap</div>
</optional-slot-demo>
Example: Without slot="optional" content in Light DOM
<optional-slot-demo>
<div slot="required">Required content</div>
<!-- expected: does not show extra whitespace -->
</optional-slot-demo>
And the results…
- ✅ Works in Safari = wrapper div hidden, no gap
- ✅ Works in Firefox = wrapper div hidden, no gap
- ✅ Works in Chrome = wrapper div hidden, no gap
The why…
I love a good CSS-trick, but even worse than a bug is not knowing why something works. My other teammate Zacky dug into the spec and confirmed this is a viable fix because as its defined @scope without a scope-root in the shadow styles allows :scope to match the shadow-host instead of the shadow-root… which is clutch. From the spec:
The :scope selector can match the featureless shadow host when that host is the scoping root element. (Issue 9025)
And…
A @scope rule without scope-start scopes to the shadow host instead of the shadow root. (Issue 9178)
Thanks, Team CSSScopeRule, for enabling this workaround (checks notes) three years ago. There’s a wonderful bit of irony that I’m using @scope to break out of the Shadow DOM’s style containment and that’s part of why I love CSS.
I look forward to the day we can use :host(:has()) and :has-slotted() in all browsers, but in the meantime I’m happy to have stumbled upon a duplicative-yet-simple fix. Let me know if you end up using this and it works for you. I already know we have a handful of minor spacing bugs to patch up in our system.