Skip to main content

How to Style Shadow DOM Components in JavaScript

One of the most compelling features of Shadow DOM is style encapsulation. Styles inside a shadow tree do not leak out, and styles from the outer page do not reach in. But this strict boundary creates a challenge: how do you allow controlled customization of a component's appearance? How do you build components that integrate with a page's design system while keeping their internals protected? Shadow DOM provides a carefully designed set of tools for this, from pseudo-classes like :host and ::slotted() to CSS custom properties that pierce the shadow boundary and the ::part() pseudo-element that exposes specific internals for styling. In this guide, you will learn every mechanism available for styling shadow DOM components, when to use each one, and how to combine them for maximum flexibility.

Styles in Shadow DOM Are Scoped

The foundation of Shadow DOM styling is scoping. When you place a <style> element inside a shadow root, its rules apply only within that shadow tree. They cannot affect elements outside, and outside styles cannot affect elements inside.

Demonstrating Scope Isolation

<style>
/* Global page styles */
h2 { color: blue; text-decoration: underline; }
.badge { background: orange; padding: 4px 8px; }
p { font-size: 20px; }
</style>

<h2>Page Heading (blue, underlined)</h2>
<p class="badge">Page badge (orange background, 20px)</p>

<my-widget></my-widget>

<script>
class MyWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
h2 { color: crimson; text-decoration: none; }
.badge { background: #27ae60; color: white; padding: 2px 6px; border-radius: 4px; }
p { font-size: 14px; }
</style>
<h2>Widget Heading (crimson, no underline)</h2>
<p class="badge">Widget badge (green background, 14px)</p>
`;
}
}
customElements.define('my-widget', MyWidget);
</script>

What renders:

  • The page heading is blue and underlined.
  • The page badge has an orange background at 20px.
  • The widget heading is crimson with no underline.
  • The widget badge has a green background at 14px.

No conflict. No specificity war. Complete isolation.

What Does Cross the Shadow Boundary

While most CSS properties are blocked by the shadow boundary, inherited properties do cross it. This is intentional and allows components to blend with the page's typography:

<style>
body {
font-family: 'Georgia', serif;
color: #333;
line-height: 1.8;
}
</style>

<inherit-demo></inherit-demo>

<script>
class InheritDemo extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<p>This paragraph inherits font-family, color, and line-height from the page.</p>
`;
}
}
customElements.define('inherit-demo', InheritDemo);
</script>

The text inside the shadow tree displays in Georgia, with color #333 and line-height 1.8, because these are inherited CSS properties.

Inherited properties that cross the shadow boundary include:

  • color, font-family, font-size, font-weight, font-style
  • line-height, letter-spacing, word-spacing, text-align
  • visibility, cursor, direction
  • And all other CSS properties that inherit by default

If you want to block all inherited properties, you can reset them:

:host {
all: initial; /* Resets ALL properties, including inherited ones */
}
caution

Using all: initial will also reset display to inline, font-family to the browser default (usually serif), and many other properties. It gives you a completely clean slate, but you will need to explicitly set everything your component needs. In most cases, selectively overriding specific inherited properties is better than a full reset.

The :host Pseudo-Classes

The :host family of pseudo-classes lets you style the host element (the custom element itself) from within the shadow tree.

:host (Basic)

The :host selector targets the element that owns the shadow root:

class StyledCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 2px solid #3498db;
border-radius: 8px;
padding: 16px;
margin: 12px 0;
font-family: system-ui, sans-serif;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>
<slot></slot>
`;
}
}
customElements.define('styled-card', StyledCard);
<styled-card>
<h3>Card Title</h3>
<p>Card content goes here.</p>
</styled-card>

The :host styles apply to the <styled-card> element itself. Without :host { display: block; }, custom elements default to display: inline, which often causes unexpected layout behavior.

External Styles Override :host

A critical rule to understand: styles set on the host element from outside always win over :host styles defined inside the shadow. This follows normal CSS specificity rules, where a direct selector on an element takes precedence over a pseudo-class selector.

<style>
/* This overrides the :host border inside the shadow */
styled-card {
border: 3px dashed red;
}
</style>

<styled-card>Content</styled-card>

The card now has a red dashed border instead of the blue solid border defined by :host. This is intentional and desirable. It means component consumers can always override the host element's styles from the outside, while :host provides sensible defaults.

:host() (Functional Form)

The :host() functional pseudo-class lets you apply styles conditionally based on whether the host element matches a specific selector:

class AlertBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 12px 16px;
border-radius: 6px;
margin: 8px 0;
border-left: 4px solid #3498db;
background: #ebf5fb;
}

/* When the host has class="success" */
:host(.success) {
border-left-color: #27ae60;
background: #eafaf1;
}

/* When the host has class="warning" */
:host(.warning) {
border-left-color: #f39c12;
background: #fef9e7;
}

/* When the host has class="error" */
:host(.error) {
border-left-color: #e74c3c;
background: #fdedec;
}

/* When the host has a "dismissible" attribute */
:host([dismissible]) .close-btn {
display: inline-block;
}

.close-btn {
display: none;
float: right;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: inherit;
opacity: 0.6;
}
.close-btn:hover { opacity: 1; }
</style>
<button class="close-btn">&times;</button>
<slot></slot>
`;

shadow.querySelector('.close-btn').addEventListener('click', () => {
this.remove();
});
}
}
customElements.define('alert-box', AlertBox);
<alert-box>Default info alert.</alert-box>
<alert-box class="success">Operation completed successfully!</alert-box>
<alert-box class="warning" dismissible>Disk space running low. Click X to dismiss.</alert-box>
<alert-box class="error">Failed to connect to server.</alert-box>

:host(.success) only applies when the host element has the success class. :host([dismissible]) only applies when the host has the dismissible attribute. This lets you style variants from inside the shadow while the consumer controls which variant to use through standard HTML attributes and classes.

:host-context() (Ancestor Matching)

The :host-context() pseudo-class matches when the host element or any of its ancestors matches the given selector. This is useful for theming, where a component adjusts its appearance based on its context in the page:

class ThemeCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 16px;
border-radius: 8px;
margin: 8px 0;
background: #ffffff;
color: #333;
border: 1px solid #ddd;
}

/* When inside a .dark-theme ancestor */
:host-context(.dark-theme) {
background: #2c3e50;
color: #ecf0f1;
border-color: #4a6785;
}

/* When inside a .compact-layout ancestor */
:host-context(.compact-layout) {
padding: 8px;
margin: 4px 0;
font-size: 14px;
}

/* When inside a sidebar */
:host-context(aside) {
border-radius: 0;
border-left: 3px solid #3498db;
border-right: none;
border-top: none;
border-bottom: none;
}
</style>
<slot></slot>
`;
}
}
customElements.define('theme-card', ThemeCard);
<!-- Normal context -->
<theme-card>Standard card</theme-card>

<!-- Dark theme context -->
<div class="dark-theme">
<theme-card>Dark themed card</theme-card>
</div>

<!-- Compact layout context -->
<div class="compact-layout">
<theme-card>Compact card</theme-card>
</div>

<!-- Sidebar context -->
<aside>
<theme-card>Sidebar card</theme-card>
</aside>

Each <theme-card> adapts its appearance based on where it is placed in the page, without requiring the consumer to add classes or attributes to the component itself.

warning

:host-context() has limited browser support. As of 2024, Firefox does not fully support it. Always check Can I Use and consider fallback strategies (like using :host() with attributes that the consumer sets manually) if you need broad browser compatibility.

The ::slotted() Pseudo-Element

The ::slotted() pseudo-element lets you style Light DOM elements that have been projected into a slot. This is the shadow tree's way of applying styles to slotted content.

Basic Syntax

class StyledList extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
::slotted(*) {
/* Applies to ALL slotted elements */
padding: 8px 12px;
margin: 4px 0;
display: block;
}

::slotted(h3) {
/* Applies only to slotted <h3> elements */
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
}

::slotted(p) {
/* Applies only to slotted <p> elements */
color: #555;
line-height: 1.6;
}

::slotted(.highlight) {
/* Applies to slotted elements with class "highlight" */
background: #fff3cd;
border-left: 3px solid #ffc107;
}
</style>
<div class="container">
<slot></slot>
</div>
`;
}
}
customElements.define('styled-list', StyledList);
<styled-list>
<h3>Section Title</h3>
<p>Regular paragraph content.</p>
<p class="highlight">This paragraph is highlighted.</p>
<p>Another regular paragraph.</p>
</styled-list>

The ::slotted(h3) rule styles the slotted heading, ::slotted(p) styles the paragraphs, and ::slotted(.highlight) adds special styling to elements with that class.

Critical Limitation: Only Top-Level Elements

::slotted() can only target the direct slotted elements, not their descendants:

<styled-list>
<div class="item">
<span class="label">Name:</span> <!-- Cannot be targeted by ::slotted() -->
<span class="value">Alice</span> <!-- Cannot be targeted by ::slotted() -->
</div>
</styled-list>
/* This WORKS - targets the direct slotted <div> */
::slotted(div.item) {
padding: 8px;
}

/* This DOES NOT WORK - cannot reach inside slotted elements */
::slotted(div.item) .label {
font-weight: bold; /* Will NOT apply */
}

/* This also DOES NOT WORK */
::slotted(.label) {
font-weight: bold; /* Won't match - .label is not a direct child of the host */
}

This limitation exists to maintain encapsulation. The component should not have deep control over the structure of consumer-provided content. If you need to style nested content within slotted elements, use CSS custom properties (covered below) or let the consumer handle those styles.

::slotted() Specificity

Styles defined with ::slotted() have lower priority than styles directly applied to the element in the Light DOM:

<style>
/* This wins over ::slotted(p) */
my-component p {
color: red;
}
</style>

<my-component>
<p>This will be red, not blue.</p>
</my-component>
// Inside shadow DOM
shadow.innerHTML = `
<style>
::slotted(p) {
color: blue; /* Loses to the outer "my-component p" rule */
}
</style>
<slot></slot>
`;

This is consistent with the principle that the component provides sensible defaults, but the consumer can always override.

Styling Named Slots

::slotted() works with named slots as well:

shadow.innerHTML = `
<style>
/* Target elements slotted into the "header" slot */
slot[name="header"]::slotted(*) {
font-size: 24px;
font-weight: bold;
}

/* Target elements slotted into the "footer" slot */
slot[name="footer"]::slotted(*) {
font-size: 12px;
color: #999;
text-align: right;
}
</style>
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
`;

You can scope ::slotted() rules to specific slots by targeting the slot element with an attribute selector first.

CSS Custom Properties for Theming

CSS custom properties (CSS variables) are the primary mechanism for piercing the shadow boundary in a controlled way. Unlike regular CSS properties, custom properties inherit through the shadow DOM, allowing consumers to influence a component's appearance without breaking encapsulation.

How It Works

The component defines custom properties with fallback values. The consumer overrides those properties from outside:

class ThemedButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
/* Use custom properties with fallback values */
background: var(--btn-bg, #3498db);
color: var(--btn-color, white);
border: var(--btn-border, none);
padding: var(--btn-padding, 10px 20px);
border-radius: var(--btn-radius, 6px);
font-size: var(--btn-font-size, 16px);
cursor: pointer;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.85;
}
</style>
<button><slot>Click me</slot></button>
`;
}
}
customElements.define('themed-button', ThemedButton);
<!-- Uses all defaults (blue button, white text) -->
<themed-button>Default</themed-button>

<!-- Override specific properties inline -->
<themed-button style="--btn-bg: #e74c3c; --btn-radius: 20px;">
Red Rounded
</themed-button>

<!-- Override via CSS class -->
<style>
.outline-theme {
--btn-bg: transparent;
--btn-color: #3498db;
--btn-border: 2px solid #3498db;
}

.large-theme {
--btn-padding: 16px 32px;
--btn-font-size: 20px;
--btn-radius: 10px;
}
</style>

<themed-button class="outline-theme">Outline</themed-button>
<themed-button class="outline-theme large-theme">Large Outline</themed-button>

Each button renders with a different appearance, all controlled from outside the shadow boundary using CSS custom properties. The component's internal structure remains completely hidden.

Building a Full Theme System

For larger components, you can expose a comprehensive set of custom properties:

class DataTable extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;

/* Document all custom properties as a "theming API" */
/* Colors */
--_header-bg: var(--table-header-bg, #2c3e50);
--_header-color: var(--table-header-color, white);
--_row-bg: var(--table-row-bg, white);
--_row-alt-bg: var(--table-row-alt-bg, #f8f9fa);
--_row-hover-bg: var(--table-row-hover-bg, #ebf5fb);
--_border-color: var(--table-border-color, #dee2e6);

/* Spacing */
--_cell-padding: var(--table-cell-padding, 10px 14px);

/* Typography */
--_font-size: var(--table-font-size, 14px);
}

table {
width: 100%;
border-collapse: collapse;
font-size: var(--_font-size);
}
th {
background: var(--_header-bg);
color: var(--_header-color);
padding: var(--_cell-padding);
text-align: left;
}
td {
padding: var(--_cell-padding);
border-bottom: 1px solid var(--_border-color);
background: var(--_row-bg);
}
tr:nth-child(even) td {
background: var(--_row-alt-bg);
}
tr:hover td {
background: var(--_row-hover-bg);
}
</style>
<table>
<thead><slot name="head"></slot></thead>
<tbody><slot></slot></tbody>
</table>
`;
}
}
customElements.define('data-table', DataTable);
<!-- Dark theme via custom properties -->
<style>
.dark-table {
--table-header-bg: #1a1a2e;
--table-header-color: #e94560;
--table-row-bg: #16213e;
--table-row-alt-bg: #1a1a2e;
--table-row-hover-bg: #0f3460;
--table-border-color: #333;
--table-font-size: 13px;
}
</style>

<data-table class="dark-table">
<tr slot="head"><th>Name</th><th>Role</th></tr>
<tr><td>Alice</td><td>Engineer</td></tr>
<tr><td>Bob</td><td>Designer</td></tr>
</data-table>
tip

The pattern of using internal private custom properties (prefixed with --_) that reference public custom properties is a best practice. The private properties use var(--public-name, fallback), which means the consumer only needs to know the public names. The private names are implementation details that you can refactor freely.

:host {
/* Public API: --table-header-bg */
/* Private internal: --_header-bg */
--_header-bg: var(--table-header-bg, #2c3e50);
}

Custom Properties vs. Other Approaches

ApproachCrosses Shadow?GranularityConsumer Control
Inherited CSS propertiesYes (by default)Limited to inheritable propsUnintentional
CSS Custom PropertiesYesFull controlIntentional, documented
::slotted()N/A (targets Light DOM)Top-level onlyComponent-defined
::part()YesSpecific partsConsumer-defined
External stylesheetsNoN/ABlocked

::part() and the part Attribute

The ::part() pseudo-element allows the component author to expose specific internal elements for direct styling by the consumer. It is more powerful than CSS custom properties because the consumer can apply any CSS property, not just the ones the component predefined.

How It Works

The component author marks internal elements with the part attribute. The consumer then styles them using the ::part() pseudo-element:

class FancyInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.wrapper {
display: flex;
align-items: center;
gap: 8px;
}
label {
font-weight: 600;
color: #333;
}
input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
outline: none;
flex: 1;
}
input:focus {
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.hint {
font-size: 12px;
color: #888;
margin-top: 4px;
}
</style>
<div class="wrapper" part="wrapper">
<label part="label"><slot name="label">Label</slot></label>
<input part="input" />
</div>
<div class="hint" part="hint">
<slot name="hint"></slot>
</div>
`;
}
}
customElements.define('fancy-input', FancyInput);

Now the consumer can style the exposed parts:

<style>
/* Style the input part of ALL fancy-input elements */
fancy-input::part(input) {
border: 2px solid #9b59b6;
border-radius: 20px;
padding: 10px 16px;
font-family: 'Courier New', monospace;
}

fancy-input::part(input):focus {
border-color: #8e44ad;
box-shadow: 0 0 0 3px rgba(142, 68, 173, 0.3);
}

fancy-input::part(label) {
color: #8e44ad;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 1px;
}

/* Target specific instances */
fancy-input.error::part(input) {
border-color: #e74c3c;
}

fancy-input.error::part(hint) {
color: #e74c3c;
font-weight: bold;
}
</style>

<fancy-input>
<span slot="label">Username</span>
<span slot="hint">Choose a unique username.</span>
</fancy-input>

<fancy-input class="error">
<span slot="label">Email</span>
<span slot="hint">This email is already taken!</span>
</fancy-input>

The consumer has full control over how each part looks, applying any CSS properties they want. The component author decides which internal elements are exposed by adding the part attribute.

Multiple Part Names

An element can have multiple part names, separated by spaces:

<!-- Inside shadow DOM -->
<button part="button primary-action">Submit</button>
<button part="button secondary-action">Cancel</button>
/* Style all buttons */
my-form::part(button) {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}

/* Style only the primary button */
my-form::part(primary-action) {
background: #3498db;
color: white;
}

/* Style only the secondary button */
my-form::part(secondary-action) {
background: transparent;
color: #555;
border: 1px solid #ccc;
}

::part() Limitations

There are important restrictions to understand:

/* WORKS: Direct part styling */
my-component::part(header) {
color: red;
}

/* WORKS: Pseudo-classes on parts */
my-component::part(input):focus {
outline: 2px solid blue;
}
my-component::part(button):hover {
opacity: 0.8;
}

/* DOES NOT WORK: Combinators after ::part() */
my-component::part(header) span {
/* Cannot select descendants of a part */
}

/* DOES NOT WORK: Chaining multiple parts */
my-component::part(header)::part(icon) {
/* Cannot chain ::part() selectors */
}

You cannot use descendant selectors, child selectors, or other combinators after ::part(). Each part is styled independently. This is intentional since it prevents consumers from reaching deeper into the component than the author intended.

exportparts for Nested Components

When components are nested (a shadow DOM inside another shadow DOM), parts from inner components are not visible to the outer document. The exportparts attribute solves this:

// Inner component
class InnerWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div part="inner-box">
<span part="inner-text">Inner content</span>
</div>
`;
}
}
customElements.define('inner-widget', InnerWidget);

// Outer component
class OuterWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div part="outer-box">
<inner-widget exportparts="inner-text: nested-text"></inner-widget>
</div>
`;
}
}
customElements.define('outer-widget', OuterWidget);
<style>
/* Style the outer part directly */
outer-widget::part(outer-box) {
border: 2px solid blue;
padding: 16px;
}

/* Style the re-exported inner part */
outer-widget::part(nested-text) {
color: crimson;
font-weight: bold;
}
</style>

<outer-widget></outer-widget>

The exportparts="inner-text: nested-text" attribute maps the inner component's inner-text part to the outer component's nested-text name. The page can then style it via outer-widget::part(nested-text).

Constructable Stylesheets and adoptedStyleSheets

Constructable Stylesheets provide a performant way to create and share CSS across multiple shadow roots without duplicating <style> elements.

The Problem with Inline Styles

When each component instance includes its own <style> element, the browser creates a separate stylesheet object for each one:

// Each instance gets its own <style> element with identical CSS
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* 50 lines of CSS duplicated per instance */
button { ... }
button:hover { ... }
button:active { ... }
/* ... */
</style>
<button><slot></slot></button>
`;
}
}

If you create 100 instances of <my-button>, you get 100 copies of the same stylesheet in memory.

The Solution: Shared Stylesheets

With CSSStyleSheet constructor and adoptedStyleSheets, you create the stylesheet once and share it across all instances:

// Create the stylesheet ONCE
const buttonStyles = new CSSStyleSheet();
buttonStyles.replaceSync(`
button {
background: var(--btn-bg, #3498db);
color: var(--btn-color, white);
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
}
button:hover {
filter: brightness(1.1);
}
button:active {
filter: brightness(0.95);
}
button:focus-visible {
outline: 2px solid var(--btn-bg, #3498db);
outline-offset: 2px;
}
`);

class SharedButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });

// Adopt the shared stylesheet - no duplication!
shadow.adoptedStyleSheets = [buttonStyles];

// No <style> element needed
shadow.innerHTML = `<button><slot>Button</slot></button>`;
}
}
customElements.define('shared-button', SharedButton);

Now, even with 100 <shared-button> instances, only one stylesheet object exists in memory. All shadow roots reference the same object.

Multiple Stylesheets

You can adopt multiple stylesheets, allowing you to compose styles:

// Base reset styles shared across ALL components
const resetStyles = new CSSStyleSheet();
resetStyles.replaceSync(`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
`);

// Typography styles
const typographyStyles = new CSSStyleSheet();
typographyStyles.replaceSync(`
:host {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
h1, h2, h3 { line-height: 1.2; }
`);

// Component-specific styles
const cardStyles = new CSSStyleSheet();
cardStyles.replaceSync(`
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
`);

class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });

// Compose multiple stylesheets
shadow.adoptedStyleSheets = [resetStyles, typographyStyles, cardStyles];

shadow.innerHTML = `<div class="card"><slot></slot></div>`;
}
}
customElements.define('my-card', MyCard);

replaceSync() vs. replace()

The CSSStyleSheet API provides two methods for setting CSS content:

const sheet = new CSSStyleSheet();

// Synchronous - use for static CSS strings
sheet.replaceSync(`
.box { color: red; }
`);

// Asynchronous - use when loading CSS from external sources
sheet.replace(`
@import url('https://example.com/styles.css');
.box { color: blue; }
`).then(() => {
console.log('Stylesheet loaded and parsed');
}).catch(err => {
console.error('Failed to load stylesheet:', err);
});

replaceSync() is the most common choice for component styles since you typically have the CSS as a string literal. replace() returns a Promise and is useful when the CSS includes @import rules that need to be fetched.

Modifying Adopted Stylesheets at Runtime

Because adopted stylesheets are live objects, you can modify them and all adopting shadow roots update immediately:

const themeSheet = new CSSStyleSheet();
themeSheet.replaceSync(`
:host {
--primary: #3498db;
--bg: white;
--text: #333;
}
`);

// Later, switch to dark theme - ALL components update instantly
function setDarkTheme() {
themeSheet.replaceSync(`
:host {
--primary: #5dade2;
--bg: #1a1a2e;
--text: #ecf0f1;
}
`);
}

// Or insert/delete individual rules
function addHighlightRule() {
themeSheet.insertRule('.highlight { background: yellow; }', themeSheet.cssRules.length);
}
tip

Adopted stylesheets open up powerful patterns for theme switching. Instead of toggling classes on every component, you modify a single shared stylesheet and every component that adopts it reflects the change immediately. This is especially efficient for large applications with hundreds of component instances.

Adopting Stylesheets on the Document

adoptedStyleSheets is not limited to shadow roots. You can also use it on the document itself:

const globalStyles = new CSSStyleSheet();
globalStyles.replaceSync(`
body { margin: 0; font-family: system-ui; }
.container { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
`);

document.adoptedStyleSheets = [...document.adoptedStyleSheets, globalStyles];

This is useful for dynamically injecting global styles without creating <style> elements in the DOM.

Choosing the Right Styling Approach

Here is a decision guide for when to use each styling mechanism:

NeedApproach
Style the host element from inside:host, :host()
Adapt to page context/ancestors:host-context()
Style top-level slotted content::slotted()
Provide a theming APICSS Custom Properties
Allow consumers to style specific partspart + ::part()
Share styles across many instancesadoptedStyleSheets
Reset all inherited styles:host { all: initial; }

These mechanisms are not mutually exclusive. A well-designed component often combines several of them:

  • CSS custom properties for colors, spacing, and typography.
  • ::part() for elements that need full consumer control.
  • ::slotted() for default formatting of projected content.
  • :host() for variant styling based on attributes or classes.
  • Constructable stylesheets for performance at scale.

Summary

ConceptKey Point
Scoped stylesStyles inside shadow DOM do not leak out; outside styles do not reach in
Inherited propertiescolor, font-family, and other inherited props cross the boundary
:hostStyles the host element from inside the shadow; overridden by external styles
:host(selector)Conditional host styling based on classes, attributes, or state
:host-context(selector)Styles based on ancestor matching; limited browser support
::slotted(selector)Styles projected Light DOM elements; top-level only
CSS custom propertiesPrimary theming mechanism; inherit through shadow boundary
part attributeMarks internal elements as stylable from outside
::part(name)Consumer-side selector for exposed parts; no combinators after it
exportpartsRe-exports parts from nested shadow DOMs
Constructable stylesheetsnew CSSStyleSheet() for creating styles programmatically
adoptedStyleSheetsShare a single stylesheet across many shadow roots; zero duplication

Shadow DOM styling is a balancing act between encapsulation and customization. The platform gives you precise tools to control exactly how much styling power you expose, from the complete lockdown of scoped styles to the full flexibility of ::part(). Understanding when to use each mechanism is the key to building components that are both robust and adaptable.