Light DOM

The LWC framework enforces shadow DOM on every component, which poses challenges for some third-party integrations and global styling. Shadow DOM encapsulates a component's internal markup and makes it inaccessible to programmatic code. Use light DOM to avoid these limitations and allow your component markup to move outside of the shadow DOM.

Let's look at how the different DOM structures render in the DOM before we dive into light DOM. Although both synthetic and native shadow behave similarly, LWC OSS uses native shadow, which appears as #shadow-root (open) tag when you inspect it.

<!-- my-app -->
<my-app>
    #shadow-root (open)
    |    <my-header>
    |        #shadow-root (open)
    |        |    <p>Hello World</p>
    |    </my-header>
</my-app>

With light DOM, the component content is attached to the host element instead of its shadow tree. It can then be accessed like any other content in the document host, providing similar behavior to content that's not bound by shadow DOM.

<my-app>
    <my-header>
        <p>Hello World</p>
    </my-header>
</my-app>

For a comprehensive overview, see Google Web Fundamentals: Shadow DOM v1.

Light DOM provides several advantages over shadow DOM.

Guidelines for Working with Light DOM

Using light DOM exposes your components to DOM scraping, so if you're working with sensitive data, we recommend using shadow DOM instead. In other words, you don't get the benefits that come with shadow DOM encapsulation, which prevents unauthorized access into the shadow tree. Since the DOM is open for traversal by other components and third-party tools, you're responsible for securing your light DOM components.

Consider encapsulating deeply nested light DOM components in a single shadow DOM component at the top level. Then, you can share styles between all the components under the shadow root.

What's Not Available for Light DOM

Enable Light DOM

Set the renderMode static field in your component class.

import { LightningElement } from 'lwc';

export default class LightDomApp extends LightningElement {
    static renderMode = 'light'; // the default is 'shadow'
}

Use the required lwc:render-mode root template directive for components that are using light DOM.

<template lwc:render-mode='light'>
    <my-header>
        <p>Hello World</p>
    </my-header>
</template>

Note

Changing the value of the renderMode static property after instantiation doesn't impact whether components render in light DOM or shadow DOM.

Work with Light DOM

Migrating a component from shadow DOM to light DOM requires some code changes. The shadow tree affects how you work with CSS, events, and the DOM. Consider the following differences when you work with light DOM.

Composition

Your app can contain components that use either shadow or light DOM. For example, my-header uses light DOM and my-footer uses shadow DOM.

<my-app>
    #shadow-root (open)
    |    <my-header>
    |        <p>Hello World</p>
    |    </my-header>
    |    <my-footer>
    |        #shadow-root (open)
    |        |    <p>Footer</p>
    |    </my-footer>
</my-app>

A light DOM component can contain a shadow DOM component. Similarly, a shadow DOM component can contain a light DOM component.

Tip

If you have deeply-nested components, consider a single shadow DOM component at the top-level with nested light DOM components. This structure allows you to freely share styles between all components within the one shadow root.

CSS

With shadow DOM, CSS styles defined in a parent component don’t cascade into a child. Contrastingly, light DOM enables styling from the parent document to target a DOM node and style it.

The styles on the following native shadow component cascades into the child component's light DOM. In this case, the light DOM component is within the native shadow component and is mounted at the nearest native shadow root level, which is scoped locally within that entire shadow root and impact any light DOM components inside of that root.

<my-app>
    #shadow-root (open)
    |    <style> p { color: green; }</style>
    |    <p>This is a paragraph in shadow DOM</p>
    |    <my-container>
    |        <p>This is a paragraph in light DOM</p>
    |    </my-container>
</my-app>

Similarly, the styles on a child component rendered in light DOM are applied to its parent components until a shadow boundary is encountered when using native shadow DOM.

For synthetic shadow DOM, the shadow DOM styles don’t cascade into the light DOM child components.

Note

In synthetic shadow DOM, styles are implemented at the global document level, but using attributes to scope. This is a current limitation for synthetic shadow DOM.

LWC doesn't scope styles automatically for you. LWC relies on shadow DOM native scoping mechanism to scope styles for shadow DOM components. To prevent light DOM component styles from cascading, we recommend using scoped styles with *.scoped.css files. See Implement Scoped Styles.

To override inherited styles in Lightning Web Components, create SLDS styling hooks in the component stylesheet. Your styling hooks act as placeholders for your custom styles. See the Blueprint Overview for a list of component blueprints that support styling hooks.

Tip

The order in which light DOM components are rendered impacts the order in which stylesheets are injected into the root node and directly influences CSS rule specificity.

Access Elements

You can retrieve a node from a light DOM component, which is helpful for third-party integrations and testing. For example, you can query the paragraph in your app using document.querySelector('p').You can't do this with shadow DOM.

<!-- JS code returns "Your Content Here" -->
<script>
    console.log(document.querySelector('my-custom-class').textContent)
</script>
<my-component>
    <div class="my-custom-class">Your Content Here</p>
</my-component>

With shadow DOM, LightningElement.prototype.template returns the component-associated ShadowRoot. With Light DOM, LightningElement.prototype.template returns null.

When migrating a shadow DOM component to light DOM, replace this.template.querySelector with this.querySelector. The following example uses a list of common DOM APIs to work with a light DOM component.

import { LightningElement } from 'lwc';

export default class LightDomApp extends LightningElement {
    static renderMode = 'light';
    query(event) {
        const el = this.querySelector('p');
        const all = this.querySelectorAll('p');
        const elById = this.getElementById('#myId');
        const elements = this.getElementsByClassName('my-class');
        const tag = this.getElementsByTagName('button');
    }
}

With light DOM components, this.querySelectorAll() can return elements rendered by other components.

The id attribute on an element is preserved at runtime and isn’t manipulated as in synthetic shadow DOM. So you can use an id selector in CSS or JavaScript because it matches the element's id at runtime.

Note

Alternatively, use this.refs when you're working with components in light DOM and shadow DOM. this.refs accesses the element that's defined in the component and behaves similarly in both light DOM and shadow DOM unlike querySelector.

Events

With shadow DOM, if an event bubbles up and crosses the shadow boundary, some property values change to match the scope of the listener. With light DOM, events are not retargeted. If you click a button that's nested within multiple layers of light DOM components, the click event can be accessed at the document level. Also, event.target returns the button that triggered the event instead of the containing component.

For example, you have a component c-light-child using light DOM nested in a container component c-light-container that's also using light DOM. The top-level c-app uses shadow DOM.

<!-- c-app (shadow DOM) -->
<template>
    <c-light-container onbuttonclick={handleButtonClick}> 
    </c-light-container>
</template>
<!-- c-light-container -->
<template lwc:render-mode="light">
    <p>Hello, Light DOM Container</p>
    <!-- c-light-child host -->
    <c-light-child onbuttonclick={handleButtonClick}>
    </c-light-child>
</template>
<!-- c-light-child -->
<template lwc:render-mode="light">
    <button onclick={handleClick}>
    </button>
</template>
// lightChild.js
import { LightningElement } from 'lwc';

export default class LightChild extends LightningElement {
    static renderMode = 'light';
    handleClick(event) {
        this.dispatchEvent(
            new CustomEvent('buttonclick',
                { bubbles: true, composed: false }
            )
        );
    }
}

When you dispatch the custom buttonclick event in c-light-child, the handlers return the following elements.

c-light-child host handler

c-light-container host handler

In contrast, if c-light-container were to use shadow DOM, the event doesn’t escape the shadow root.

Slots

Slots are emulated in light DOM since there is no browser support for slots outside of shadow DOM. Slots in light DOM behave similarly to synthetic shadow slots. LWC determines at runtime if a slot is running light DOM.

Let's say you have a component my-component with a named and unnamed slot.

<!-- my-component.html -->
<template>
   <slot name="title"></slot>
   <h3>Subtitle</h3>
   <slot></slot>
</template>

Use the component like this.

<my-component>
   <p>Default slotted content</p>
   <h1 slot="title">Component Title</h1>
</my-component>

These slots are not rendered to the DOM. The content is directly appended to the host element in the DOM.

<my-component>
   <h1>Component Title</h1>
   <h3>Subtitle</h3>
   <p>Default slotted content</p>
</my-component>

The slotted content or fallback content is flattened to the parent element at runtime. The <slot> element itself isn't rendered, so adding attributes or event listeners to the <slot> element throws a compiler error.

Additionally, consider this light DOM component c-light-slot-consumer that contains a shadow DOM component c-shadow-slot-container and light DOM component c-light-slot-container.

<!-- c-app -->
<template>
   <c-shadow-component></c-shadow-component>
   <c-light-slot-consumer></c-light-slot-consumer>
</template>
<!-- c-light-slot-consumer -->
<template lwc:render-mode="light">
   <c-shadow-slot-container>
       <p>Hello from shadow slot</p>
   </c-shadow-slot-container>
   <c-light-slot-container>
        <p>Hello from light slot</p>
   </c-light-slot-container>
</template>
<!-- c-shadow-slot-container -->
<template>
   <slot></slot>
</template>
<!-- c-light-slot-container -->
<template lwc:render-mode="light">
   <slot name="other">
       <p>Hello from other slot</p>
   </slot>
   <slot>This is the default slot</slot>
</template>

If you include styles in c-app, all elements within the slots (in both the shadow DOM and light DOM components) get the styles. However, notice that the shadow DOM component c-shadow-component without slots doesn't receive the styles.

<c-app>
    <style type="text/css">p { background: green;color: white; }</style>
    <h2>Hello Light DOM</h2>
    <p>This is a paragraph in app.html</p>
    <h3>Shadow DOM</h3>
    <c-shadow-component>
        #shadow-root (open)
        |    <p>Hello, Shadow DOM container</p>
    </c-shadow-component>
    <h3>Slots</h3>
    <c-light-slot-consumer>
        <c-shadow-slot-container>
            #shadow-root (open)
            |    <p>Hello from shadow-slot-container</p>
        </c-shadow-slot-container>
        <c-light-slot-container>
            <p>Hello from other slot</p>
            <p>Hello from light-slot-container</p>
        </c-light-slot-container>
    </c-light-slot-consumer>
</c-app>

Consider these composition models using slots. A component in light DOM can slot in content and other components. The slots support both light DOM and shadow DOM components.

<template>
    <slot name="content">Default content in the named slot</slot>
    <p>This makes the component a bit more complex</p>
    <slot>This is a default slot to test if content bypasses the named slot and goes here</slot>
</template>

Here's how your content is rendered in the slots.

<my-component>
    <!-- Inserted into the content slot -->
    <div slot="content">Some text here</div>
</my-component>

<my-component>
    <!-- Inserted into the content slot -->
    <my-shadow-lwc slot="content">Some text here</my-shadow-lwc>
</my-component>

<my-component>
    <!-- Inserted into the content slot -->
    <my-light-lwc slot="content">Some text here</my-light-lwc>
</my-component>

<my-component>
    <!-- Inserted into the default slot -->
    <my-shadow-lwc>Some text here</my-shadow-lwc>
</my-component>

<my-component>
    <!-- Inserted into the default slot -->
    <my-light-lwc>Some text here</my-light-lwc>
</my-component>

Note

The slotchange event and ::slotted CSS pseudo-selector are not supported since the slot element does not render in the DOM.

Light DOM doesn't render slotted elements that are not assigned to a slot, which means that their lifecycle hooks are never invoked.

<!-- c-parent -->
<template>
    <c-child>
        <span>This element is not rendered in light DOM</span>
    </c-child>
</template>

<!-- c-child -->
<template>
    <p>This component does not include a slot</p>
</template>

Using slots inside a for:each or iterator:* loop is not supported. For example:

<!-- This results in a runtime error -->
<template for:each={items} for:item="item">
    <div key={item.id}>
        <slot></slot>
    </div>
</template>

Compare Light DOM and Shadow DOM

The recommended way to author components is with shadow DOM, due to its strong encapsulation. It hides your component's internals so consumers can only use its public API. However, shadow DOM isn't suitable in the following cases.

Light DOM is a better fit in those cases, but note that consumers can access your component's internals as they can with your public API. This makes it challenging to implement changes without impacting your consumer's code.

Here are pros and cons in using one over the other.

Shadow DOM Light DOM
Advantages + Strong component encapsulation
+ Portability
+ Easy to override styles
+ Simple integration with third-party tools
Drawbacks - Requires CSS custom properties to override styles
- Limited compatibility with third-party tools requiring DOM traversal or event retargetting
- Weak encapsulation
- Susceptible to breaking changes caused by component authors or consumers

Consider these guidelines when using both light and shadow DOM in your app.

When working with third-party libraries, such as Google Analytics or another instrumentation library, you don't have to use light DOM if your shadow DOM component exposes the right APIs like in the following example. While this is not always possible, we recommend you explore both options before selecting the best fit for your use case.

Let's say you want to instrument click interactions on a button.

<!-- my-button example -->
<template>
    <button>{label}</button>
</template>

With light DOM, you can attach a click event listener on the <button> element. If you render the component in shadow DOM, the <button> element is not accessible from outside the component. Furthermore, you can consider the <button> to be an internal implementation detail of the <my-button> component. In this case, the correct approach to instrument this component is to add a click handler on the <my-button> itself to instrument it. Expose only the bare minimum you want to instrument since exposing your internal events can weaken your component's encapsulation.

Scoped Slots

With scoped slots, you can access data in a child component and render it in slotted content inside of a parent component. Binding data from the child component to the scoped slot allows the parent component to reference the child component’s data in the slotted content. This data is slotted in the child component’s light DOM.

To use scoped slots, the child component must use light DOM. Scoped slots in shadow DOM aren’t supported. The parent can be a light DOM or shadow DOM component.

In this example, the child component <x-child> binds its item data to the scoped slot <slot>. The parent component <x-parent> references {item.id} and {item.name}, which use data from <x-child> in the slotted content.

<!-- x/parent.html -->
<template> <!-- Parent component doesn’t need to be light DOM -->
    <x-child>
        <template lwc:slot-data="item">
            <span>{item.id} - {item.name}</span>
        </template>
    </x-child>
</template>


<!-- x/child.html -->
<template lwc:render-mode="light"> <!-- Child must be light DOM -->
    <ul>
        <template for:each={item} for:item="item">
            <li key={item.id}>
                <slot lwc:slot-bind={item}</slot>
            </li>
        </template>
    </ul>
</template>

Because the scoped slot fragment is in the parent component’s template, the parent component owns the slotted content. So if the parent component references a scoped stylesheet, those styles also apply to the content of the scoped slot. The parent component partially renders the content of the scoped slot, so it must be enclosed in <template></template> tags. In the example, the scoped slot content is <span>{item.id} - {item.name}</span>. The parent component creates this partial fragment. The parent component also renders each item, and the child component controls the loop logic. <x-child> creates as many slots as needed using the same template fragment passed from <x-parent>.

The final HTML looks as follows.

<x-parent>
    #shadow-root
    |    <x-child>
    |        <ul>
    |            <li>
    |                <span>1 - One</span>
    |            </li>
    |            <li>
    |                <span>2 - Two</span>
    |            </li>
    |        </ul>
    |    </x-child>
</x-parent>

To introduce scoped slots into your components, add the directives lwc:slot-bind and lwc:slot-data. For more information, see HTML Template Directives.

Multiple Scoped Slots and Bindings

A child component can have multiple named scoped slots, but it can have only one default scoped slot.

<template>
    <x-child>
        <template lwc:slot-data="defaultdata"> <!-- This is a default slot -->
            <p>{defaultdata.title}</p>
        </template>
        <template slot="slotname1" lwc:slot-data="slot1data"> <!-- This is a named slot -->
            <p>{slot1data.title}</p>
        </template>
        <template slot="slotname2" lwc:slot-data="slot2data"> <!-- This is a named slot -->
            <p>{slot2data.title}</p>
        </template>
    </x-child>
</template>

You can bind different scoped slots to the same source of data. In the example below, the default scoped slot and the two named scoped slots render content from slotdata.

<template lwc:render-mode="light">
    <slot lwc:slot-bind={slotdata}></slot> <!-- This is a default slot --!>
    <slot name="slotname1" lwc:slot-bind={slotdata}></slot> <!-- This is a named slot --!>
    <slot name="slotname2" lwc:slot-bind={slotdata}></slot> <!-- This is a named slot --!>
</template>

You can bind a scoped slot to only one source of data. For example, binding the named scoped slot namedslotA to two different sets of data, slot1data and slot2data, results in a compiler error.

<!-- Invalid usage of named scoped slot -->
<template lwc:render-mode="light">
    <slot name="namedslotA" lwc:slot-bind={slot1data}></slot>
    <slot name="namedslotA" lwc:slot-bind={slot2data}></slot>
</template>

If you try to bind a default scoped slot to multiple different sets of data, the compiler throws the same error.

<!-- Invalid usage of default scoped slot-->
<template lwc:render-mode="light">
    <slot lwc:slot-bind={slot1data}></slot>
    <slot lwc:slot-bind={slot2data}></slot>
</template>

Mixing Standard and Scoped Slots

Because you can only have one default slot in a component, you can’t place a standard default slot and a default scoped slot in the same component. The following code results in an error.

<!-- x/child.html -->
<!-- Invalid usage of default slots -->
<template lwc:render-mode="light">
    <slot lwc:slot-bind={slotdata}>Default scoped slot</slot>
    <slot>Standard default slot</slot>
</template>

Within a child component, a named scoped slot and a standard named slot can’t share the same name. The following code results in an error.

<!-- x/child.html -->
<!-- Invalid usage of named slots -->
<template lwc:render-mode="light">
    <slot name="slotname1" lwc:slot-bind={slotdata}>Named scoped slot</slot>
    <slot name="slotname1">Standard named slot</slot>
</template>

When binding a scoped slot in a parent component to data from a child component, the components must contain the same type of slot. For example, if a parent component contains a scoped slot bound to a child component, that child component must also have a scoped slot. Otherwise, the slotted content isn’t rendered. If you enable debug mode, an error is also logged in the dev console.

Flexibility of Scoped Slots

You can nest a scoped slot inside another scoped slot.

<template>
    <x-table data={data}>
        <template lwc:slot-data="row">
            <x-row row={row}> <!-- This is rendered for every row in the table -->
                <template lwc:slot-data="column">
                    <span> <!-- This is rendered for every column in the row -->
                        Coordinates: {row.number} - {column.number} <!-- This can refer to both `row` and `column` -->
                    </span>
                </template>
            </x-row>
        <template>
    </x-table> 
</template>

Scoped slots can reference component bindings and scope bindings.

<template>
    {title}
    <x-list>
        <template lwc:slot-data="item">
            <div>{label}</div> <!-- label is a component binding that’s repeated in every row of the list -->
            <span>{item.id} - {item.name}</span>
        </template>
    </x-list>
</template>

Implement Scoped Styles

Scoped styles apply CSS to elements on the component only, similar to style encapsulation in shadow DOM.

To add scoped styles on a component, create a *.scoped.css file in the component folder.

myCmp
    ├──myCmp.html
    ├──myCmp.css
    └──myCmp.scoped.css

You can include either one, both, or neither of the CSS files for a light or shadow DOM component.

Note

In Aura-based containers, light DOM components can only load scoped styles. You must include the *.scoped.css file for your custom components instead of the .css file.

Let's take a look at an example of a light DOM component with scoped styles.

<!-- c-light-cmp -->
<template lwc:render-mode="light">
   <p>This is a paragraph in c-light-cmp</p>
</template>
// lightCmp.js
import { LightningElement } from 'lwc';

export default class LightCmp extends LightningElement {
   static renderMode = 'light';
}
/* lightCmp.scoped.css */
p {
   background: silver;
   color: black;
}

If a *.css file is used with a *.scoped.css file, the *.css stylesheets are injected before the *.scoped.css stylesheets. The scoped style CSS selectors have precedence over the unscoped ones because they are declared last.

Note

If a scoped stylesheet and an unscoped are used on the template, both stylesheets are applied. The injection order influences which styles should be applied by the browser when you have duplicated selectors. See tree proximity ignorance.

/* lightCmp.css */
p {
   background: yellow;
   color: #777;
}

In this case, the c-light-cmp uses the scoped styles, but the styles from the unscoped stylesheet can bleed out of the component.

For example, it can apply to its parent components until a shadow boundary is encountered when using native shadow DOM. See Targeting the Root Element.

<!-- c-app -->
<template>
   <!-- This paragraph is styled yellow from lightCmp.css -->
   <p>This is a paragraph in c-app shadow DOM</p>
   <c-light-cmp></c-light-cmp>
</template>

The paragraph in c-app inherits the styles from lightCmp.css. To override the styles from lightCmp.css, include a scoped stylesheet app.scoped.css.

:::caution We don't recommend using the !important rule as it makes debugging more difficult. When you use this rule on a style declaration in both your scoped and unscoped stylesheet, the scoped style has a greater specificity and its style is applied on the component. :::

Scoped Region

When you use *.scoped.css, the CSS selectors are scoped to all elements in the component HTML file.

<!-- c-my-cmp -->
<template>
   <div>
       <span></span>
       <button class="my-button"></button>
   </div>
</template>

In the following CSS, all selectors match the elements in the template, and only those elements.

/* myCmp.css */
div {}
span {}
button {}
div > span {}
div button {}
.my-button {}

The root element c-light-cmp can be targeted using :host, even in a light DOM component.

CSS scoping leverages custom CSS classes to prevent styles from leaking out of the component.

Targeting the Root Element

As light DOM styles aren't scoped by default, :host pseudo selector refers to the closest shadow root host element (if any).

Contrastingly, scoped styles imitate the encapsulation of shadow DOM components. With scoped styles, the :host pseudo-selector refers to the root element of the light DOM component, which is the root of the scoped DOM region.

Let's say you have an unscoped and scoped stylesheet on a light DOM component.

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

The component renders:

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

In the above example, observe that :host is transformed for the scoped style.

Note

The :host-context() selector is not supported.

Comparisons with Synthetic Shadow DOM

Light DOM scoped styles have several key differences with LWC's synthetic shadow scoped styles.

Feedback and Comments

We are continuously collecting usage metrics to better understand the behavior and performance of light DOM in our customers' solutions. If you have existing LWC components that benefit from using light DOM, feel free to share feedback on any light DOM conversion issues you experience.

Your feedback is welcome! Provide your comments, report bugs, or make feature requests in the LWC repo.