Webcomponents: "open-stylable" Shadow Roots

Created on 10 Dec 2020  ·  8Comments  ·  Source: WICG/webcomponents

We keep hearing that the strong style encapsulation is a major hinderance - perhaps the primary obstacle - to using shadow DOM.

The problems faced are usually a variant of trying to use a web component in a legacy context where global styles of the page are expected to be applied deeply through out the DOM tree, or a modern context with tools that don't work with scoping, eg Tailwind.

I've see requests to workaround encapsulation formed in a number of ways:

  1. Turn off shadow DOM (as is an option in LitElement), but keep <slot> working (which obviously isn't).
  2. Allow styles to apply from the outside
  3. Add a way to automatically inject page styles into shadow roots.
  4. Implement a custom CSS scoping mechanism for when shadow dom is off
  5. Some specific library, eg Tailwind, converted to work with shadow roots
  6. etc...

Would it be possible to address some of these difficulties with scoping more or less directly, by adding a new shadow root mode that allows selectors to reach into a shadow root from above? Something like:

this.attachShadow({mode: 'open-stylable'});

Application developers could then make sure that elements from the root of the page on down to where they need styles to apply use open-stylable shadow roots. Library authors could offer control over the mode for legacy situations, etc.

I'm not sure how combinators would work here. ie, would .a > .b {} apply to <div class="a"><x-foo>#shadow<div class="b">? It may be that's not even needed to be able to use most of the stylesheets in question, which often rely more heavily on classes than child/descendent selectors.

Would this be viable performance-wise?

Related to #864

Most helpful comment

With adopted stylesheets, how bad would it be to apply the same stylesheet to all elements? Is there a negative performance impact even when it's same stylesheet in memory?

Component libraries could offer easy mechanisms to inject those global stylesheets into the components.

All 8 comments

We can't evaluate a selector across shadow boundaries because of the way we architected our engine, and we don't want to change that.

I think there's something to be said for this idea:

Turn off shadow DOM (as is an option in LitElement), but keep \

Possibly a bit off topic, but StencilJS went through great lengths to support both ShadowDOM and non-ShadowDOM, using the same component / syntax. It sounds like the biggest challenge was how to emulate slots without the help of ShadowDOM. Performance is an issue with this emulation, apparently.

I think userland has demonstrated that the slot concept is quite useful, even without ShadowDOM. React has something roughly similar, perhaps.

I'm guessing the most natural fit for supporting this would be as part of the template instantiation initiative. I know that initiative is currently awaiting a determination whether there are underlying primitives that would benefit the platform more generally.

Perhaps in parallel to that effort, template instantiation could start, but focus squarely on this (additional?) requirement, which does seem to be quite widely applicable, but much harder to implement in a performant way than other features of that proposal? Or maybe there are also some underlying primitives that would make slot emulation easier to implement / faster performing?

If not, I would still suggest looking at supporting slots when/if the discussion does move on to actual declarative template instantiation.

With adopted stylesheets, how bad would it be to apply the same stylesheet to all elements? Is there a negative performance impact even when it's same stylesheet in memory?

Component libraries could offer easy mechanisms to inject those global stylesheets into the components.

@rniwa interesting, thanks. If selectors weren't evaluated across shadow boundaries, would it be possible to run them whole inside a child shadow root?

@LarsDenBakker I was wondering if an open-stylable concept could essentially be that open-stylable roots inherit the sheets from the scope above them. There are a lot of tricky problems with a userland library trying to make this work. You need mutation observers to listen for all <style> and <link> elements, and since their stylesheets are not adoptable, you couldn't get changes to them. You'd also need to patch adoptedStyleSheets.

@justinfagnani That idea does indeed sound tricky but assuming we have CSS modules you could easily build a Tailwind class mixin that an app team folds into their base class, no?

@bahrus I don't think <slot> makes much sense outside of shadow DOM because you end up with contention of the children. Composition works because the light and shadow DOM are separated.

Say a component rendering to it's own light DOM, something like:

<my-card>
  <slot name="title">Alert</slot>
  <slot></slot>
  <button>OK</button>
</my-card>

And then a user would like to use it:

<my-element>
  <h1 slot="title">Welcome</h1>
  <p>Thanks for reading my card...</p>
</my-element>

What should render? When? We have two timings to deal with: 1) the usual content exists before element's 2) the element's content exist before the users. In 1) The element could accidentally overwrite the user's content. Or it could append to itself. Then it would have to move the user's content into the slot, but the user wouldn't know that and could keep appending into what's now the element's semi-private DOM. In 2) If the user uses a template system like vdom, they may remove all the element's content.

The only way to get this to work in any stable way is make a contract that separates user-provided children from element provided with a special child element. Then either all user provided content must go in the child, or all element provided:

And then you can't really use <slot> because that would be projecting content from an outer scope. So i'll invent <content> and <content-slot> to allow projection between siblings:

<my-element>
  <content>
    <h1 slot="title">Welcome</h1>
    <p>Thanks for reading my card...</p>
  </content>
  <content-slot name="title">Alert</content-slot>
  <content-slot></content-slot>
  <button>OK</button>
</my-element>

This gets really complicated really quick. There's a reason I keep rejecting this feature in LitElement.

@matthewp yeah, for any given CSS library we can probably formulate a way to make that library more compatible with shadow DOM. Component authors might not have the time, ability, or context to do so though - they might not know what context they're running in exactly - what specific style sheets are used - but they might know that the existing DOM they're replacing is styled via certain class names. This is when we hear that for some projects it's simply not feasible to use shadow DOM.

I am wary of the all-or-nothingness of open-stylable in two ways as it basically makes it impossible to actually isolate some styles.

e.g. Suppose you want to use tailwind at the root level, this means that every shadow root needs to be open-stylable. But now what do I do if I want to isolate styles to a shadow root? If I add the following sheet to my shadow root:

<style>
    #container {
        --some-styles: some-values;
    }
</style>

Then I have unintentionally targeted any element that happens to use id="container" within nested components.


My inclination is that the best way for dealing with stylesheets not designed for shadow roots is just to do what @matthewp suggested and add the stylesheets that need to be inherited into each root.

I think it would be good though if StyleSheet and StyleSheetList had change events so that we could respond to changes more accurately. (This would be useful beyond this use case as well, e.g. for building things like StyleObserver, etc).

they might not know what context they're running in exactly - what specific style sheets are used - but they might know that the existing DOM they're replacing is styled via certain class names.

This is another place where an API would be nice, e.g. give it an element and gets a list of rules that target that element e.g.:

const el = document.createElement("div");
el.classList.add("my-class");

const rules = document.getMatchingRules({
  element: el,
  // Allow matching rules if it were in the DOM, without actually needing to add it to the DOM
  where: {
    anchorElement: document.body,
    relativePosition: "child", // Or nextSibling, previousSibling etc
  },
});

const sheets = rules.map(rule => rule.parentStyleSheet);
// Do whatever with sheets

Like above, this would be more generally useful for building things like StyleObserver, css polyfills/extensions/etc.

Was this page helpful?
0 / 5 - 0 ratings