Styling Custom Elements Without Reflecting Attributes
I've been struggling with the idea of reflecting attributes in custom elements and when it's appropriate. I think I've identified a gap in the platform, but I'm not sure exactly how we should fill it.
I'll explain with an example. Let's say I want to make a simple badge component with primary, secondary, and tertiary variants.
<my-badge variant="primary">foo</my-badge>
<my-badge variant="secondary">bar</my-badge>
<my-badge variant="tertiary">baz</my-badge>
This is a simple component, but one that demonstrates the problem well. I want to style the badge based on the variant property, but sprouting attributes (which occurs as a result of reflecting a property back to an attribute) is largely considered a bad practice. A lot of web component libraries do it out of necessary to facilitate styling — including Shoelace — but is there a better way?
The problem #
I need to style the badge without relying on reflected attributes. This means I can't use :host([variant="..."])
because the attribute may or may not be set by the user. For example, if the component is rendered in a framework that sets properties instead of attributes, or if the property is set or changed programmatically, the attribute will be out of sync and my styles will be broken.
So how can I style the badge based its variants without reflection? Let's assume we have the following internals, which is all we really need for the badge.
<my-badge>
#shadowRoot
<slot></slot>
</my-badge>
What can we do about it? #
- I can't add classes to the slot, because
:host(:has(.slot-class))
won't match. - I can't set a data attribute on the host element, because that's the same as reflection and might cause issues with SSR and DOM morphing libraries.
- I could add a wrapper element around the slot and apply classes to it, but I'd prefer not to bloat the internals with additional elements. With a wrapper, users would have to use
::part(wrapper)
to target it. Without the wrapper, they can set background, border, and other CSS properties directly on the host element which is more desirable. - I could add custom states for each variant, but this gets messy for non-Boolean values and feels like an abuse of the API.
Filling the gap #
I'm not sure what the best solution is or could be, but one thing that comes to mind is a way to provide some kind of cross-root version of :has
that works with :host
. Something akin to:
:host(:has-in-shadow-root(.some-selector)) {
/* maybe one day… */
}
If you have any thoughts on this one, hit me up on Twitter.