Status: DRAFTED

Champion: Nolan Lawson (nolanlawson), Abdulsattar Mohammed (abdulsattar)

Revision: latest

RFC created: 2021/05/14

Last updated: unknown

RFC: https://github.com/salesforce/lwc-rfcs/pull/50

This RFC has been merged

Light DOM Scoped Styles

Summary

This proposal adds a mechanism for components (including light DOM components) to have co-located CSS files that are scoped to that component, but without relying on shadow DOM.

Basic example

Since there is already *.css for unscoped light DOM CSS files, we add *.scoped.css for scoped light DOM CSS:

<!-- x/foo.html -->
<template lwc:render-mode="light">
  <div>Hello</div>
</template>
// x/foo.js
import { LightningElement } from 'lwc'

export default class Foo extends LightningElement {
  static renderMode = 'light'
}
/* x/foo.scoped.css */
div {
  color: green;
}

The result will be:

<x-app>
  <style class="x-foo_foo" type="text/css">
    div.x-foo_foo { color: green; }
  </style>
  <div class="x-foo_foo">Hello</div>
</x-app>

Motivation

For (light DOM components), the default assumption is that styles are unscoped. In other words, a light DOM component essentially just contains <style> tags that are inserted into the DOM in situ. (The actual implementation may be slightly different, but this is the basic mental model.)

Many frameworks, however, have a concept of scoped styles, even without using shadow DOM. These offer good developer ergonomics, because developers can concentrate on the CSS co-located with a particular component, without worrying how that CSS might affect other components.

Prior art:

Furthermore, scoped styles are useful even for shadow DOM. Consider a shadow parent component with a light child component: the parent's styles "bleed" into the child, which may be surprising for developers who are accustomed to the Vue/Svelte model. (Note that this only applies to native shadow DOM, not synthetic shadow DOM.)

Invariants

Whatever we design, we would prefer for it to:

  1. Use standard CSS syntax rather than non-standard CSS syntax.
  2. Be compatible with the emergent CSS Scoping Proposal (@scope).

Detailed design

File format

A new *.scoped.css file can be used alongside an existing *.css file. The component author can include either one, both, or neither.

The *.scoped.css file is automatically picked up by the compiler, similar to the existing *.css file.

In terms of ordering, *.css stylesheets are injected before *.scoped.css stylesheets.

Scoped region

Inside of *.scoped.css, the CSS selectors are scoped to all elements defined in the template HTML file. For instance:

<!-- foo.html -->
<template>
  <div>
    <span></span>
    <button class="my-button"></button>
  </div>
</template>
/* foo.scoped.css */
div {}
span {}
button {}
div > span {}
div button {}
.my-button {}

In the above CSS, all selectors would match the relevant elements in the template, and only those elements.

In addition, the root element (in this case x-foo) can also be targeted using :host (even in a light DOM component):

:host {}

More on this below.

Scoping token

For the purposes of this document, a scoping token is a string used to scope CSS selectors to a particular region of the DOM.

In general, there are two approaches for applying scoping tokens to the DOM: classes or attributes. Both have the same CSS specificity, but historically and presently, classes are faster than attributes in terms of the browser's style calculation process. So we prefer classes.

This means that, with scoped styles, all CSS selectors inside of the *.scoped.css file will have an added class, which scopes the rules to that particular component.

For example, the user might author:

div > .bar {}

This is compiled to:

div.x-foo_foo > .bar.x-foo_foo {}

Any existing classes in the template will be merged with the scoping token classes.

Comparisons with synthetic shadow DOM

The current design of LWC's synthetic shadow scoped styles has several features that we do not want to emulate with light DOM scoped styles:

The classes vs attribute issue is already addressed above. So let's cover the other differences.

@import

For the time being, @import will be disallowed within *.scoped.css files. It may be enabled in the future, but for now, the invariant that "only files ending in .scoped.css are scoped" makes the implementation much simpler, and matches other scoped CSS implementations such as Vue and Svelte, which do not allow @import. (In addition, native constructable stylesheets do not support @import.)

lwc:dom="manual" and MutationObserver

Consider this example:

<!-- example.html -->
<template>
  <div lwc:dom="manual"></div>
</template>
/* example.css */
span {
    color: green;
}
// example.js
class Example extends LightningElement {
  renderedCallback() {
    this.template.querySelector('div').appendChild(document.createElement('span'))
  }
}

In this case, the <span> is dynamically inserted into the component's shadow DOM, but synthetic shadow DOM needs some way to monitor the DOM for changes so that it can apply the styling token. This is where lwc:dom="manual" comes in – it's a signal to add a MutationObserver to track changes.

This is a lot of extra machinery to support style scoping. Users need to know about lwc:dom="manual", and the framework needs to create and disconnect a MutationObserver.

We would like to avoid this for light DOM style scoping, so we have a simpler system: dynamically-inserted elements are not scoped. Incidentally, this is how Vue and Svelte scoped styles work – there's no expectation that you can mutate the DOM with vanilla DOM APIs and still have the scoping token applied.

Targeting the root element

With global light DOM styles, the :host selector is inserted as-is. This means that a light DOM child component can use :host to target its shadow parent's host container.

With scoped styles, the intuition around :host should be a bit different. Rather than inserting the CSS as-is, scoped styles imitate the encapsulation of shadow DOM components. So it makes sense for :host to follow shadow DOM-like semantics. This means that :host should refer to the root element of the light DOM component – i.e., the root of the scoped DOM region.

So for instance:

/* light.css */
:host {
    background: red;
}
/* light.scoped.css */
:host {
    background: blue;
}

This would render:

<x-shadow> <!-- red background -->
  #shadow-root
    <x-light class="x-light_light-host"> <!-- blue background -->
      <style>
        :host { background: red; }
        .x-light_light-host { background: blue; }
      </style>
    </x-light>
</x-shadow>

In the above example, observe that :host is inserted as-is for the global style, whereas :host is transformed for the scoped style.

In order for :host to properly mimic shadow DOM semantics, it also needs a separate styling token from other selectors. Consider this example:

/* light.scoped.css */
:host {
    background: blue;
}
* {
    background: green;
}
<!-- light.html -->
<template>
  <div>Hello</div>
</template>

This should render the HTML:

<x-light class="x-light_light-host"> <!-- blue background -->
  <style>
    .x-light_light-host { background: blue; }
    *.x-light_light { background: green; }
  </style>
  <div class="x-light_light">Hello</div> <!-- green background -->
</x-light>

This matches the same developer intuition at play with native shadow DOM, where * refers to elements defined inside the <template>, whereas :host refers to the "root" containing element.

Note that :host-context() is not supported because it lacks buy-in from Apple and Mozilla.

Drawbacks

Conceptually, it's a bit awkward that foo.css in a shadow DOM component is "scoped," whereas foo.css is "unscoped" for a light DOM component (using naïve developer expectations about shadow DOM scoping). However, this default behavior actually matches the native DOM behavior: when you insert a <style> into a shadow-DOM-using component, it's scoped to that shadow root, whereas a light DOM component doesn't have shadow DOM to make the same guarantee.

It's also a bit awkward that scoped light DOM styles behave differently from shadow DOM styles. Developers will have to understand the difference, and in some cases perhaps prefer shadow DOM over light DOM (e.g. having one shadow root wrapper component around multiple light DOM components).

Alternatives

Not scoping

We could simply not implement scoped CSS for light DOM. However, given how popular it is in other frameworks (Svelte, Vue, the wide ecosystem of React CSS-in-JS libraries), it seems like a shame for LWC not to support it.

We have also had feedback from pilot customers that light DOM style scoping is a must-have.

Descendant selectors

One alternative is to use CSS selectors that can style all of a component's descendants. For instance:

/* input */
div {}
/* output */
.scope-token div {}

This approach was rejected because, although it's similar to the style scoping used in Aura, it's dissimilar from the style scoping used in native shadow DOM, Vue, Svelte, Stencil, styled-components, etc. Our hope is that the current approach will be more familiar to more developers.

If developers want a component to contain styles that affect its children, it's always possible to use non-scoped light DOM styles, and to target the child component's classes, attributes, etc.:

/* parent.css */
.my-child {}
<!-- child.html -->
<template lwc:render-mode="light">
  <div class="my-child"></div>
</template>

This is the same solution one might use with global styles in Vue (<style>) or Svelte (:global()).

Targeting the root element with x-component

In theory, a light DOM component can target its own root element in both global and scoped styles by using its own tag name, e.g. x-component:

x-component {
    background: red;
}

The problem with this approach is that templates do not really "own" their tag names. A subclass of a LightningElement, for instance, could end up with a totally different tag name. Or we may rename tags at the platform level. Therefore it's not safe for developers to use this technique to "guess" their own tag name.

This is why we support the :host selector in scoped CSS instead.

:global()

CSS Modules and Svelte both allow the :global() modifier to selectively disable scoping:

:global(.foo) .bar {} /* .foo is global, .bar is scoped */
.foo :global(.bar) {} /* .foo is scoped, .bar is global */
:global(.foo .bar) {}  /* both .foo and .bar are global */

We choose not to adopt this modifier, because it is non-standard and may never be on the standards track. Whereas with the CSS Scoping proposal, we could potentially remove most of the CSS transforms and use the browser's built-in scoping behavior. (The exception is :host, but :host is at least a standard pseudo-class.)

*.global.css

Another alternative is to make foo.css the default (scoped) stylesheet, and foo.global.css the global stylesheet.

This solution has some benefits, namely that styles are conceptually "scoped" by default for both light DOM and shadow DOM components. It also encourages developers to use "scoped" by default, which is a good practice for encapsulation.

However, the biggest drawback is that *.global.css wouldn't work well with enabling scoped styles for shadow DOM components. This may seem redundant, but it's actually valuable in the case of a light child within a shadow parent:

<x-shadow>
    #shadow-root
        <x-light></x-light>
</x-shadow>

A developer may naïvely assume that styles are "scoped" to both <x-shadow> and <x-light> as they are in frameworks like Vue and Svelte. However, that's not true – styles from <x-shadow> will "bleed" into <x-light>. (Sometimes this is desired, sometimes not.)

By using the *.css / *.scoped.css system, *.css acts as "global" (i.e. injected in situ with no transformations) consistently for both light DOM and shadow DOM components. *.scoped.css is also consistently applied in both cases.

For developers who prefer to have styles scoped on a per-component basis, regardless of light or shadow DOM, they can use *.scoped.css in all their components, and everything will "just work." (Sometimes the scoping will be redundant, e.g. for shadow components within other shadow components, but it's just extra classes.)

To be clear: we will not disable *.scoped.css for shadow DOM components – it will work in all LWC components.

Adoption strategy

Scoped styles would be opt-in, using *.scoped.css. Existing CSS (in *.css files) would not be affected.

How we teach this

Conceptually, scoped light DOM styles will have to be bundled up into a larger discussion of light DOM versus shadow DOM. Because switching between the two will never be as simple as flipping a boolean flag, and because the differences between the two can have a wide-ranging impact, we have to be careful about how we communicate the differences to developers.

Unresolved questions

There are no unresolved questions at this time.

undefined