Styling From Inside

The four CSS pseudo-selectors that only exist inside a shadow root: :host, :host-context(), ::slotted(), and :not(:defined) -- plus the adoptedStyleSheets API.

June 12, 20266 min read3 / 3

Once I started authoring web components rather than just using them, the CSS tools available shifted completely. The shadow boundary was no longer something my selectors hit and stopped at -- it was where my styles lived and where they stayed contained.

Inside a shadow root, four pseudo-selectors exist that have no equivalent in regular stylesheets. Each solves a problem you would not be able to solve any other way.

The four CSS pseudo-selectors available inside a shadow root, and the adoptedStyleSheets API ExpandThe four CSS pseudo-selectors available inside a shadow root, and the adoptedStyleSheets API

:host

:host selects the custom element itself -- the outer element your component is attached to -- from inside the shadow root.

CSS
/* Inside the shadow root */ :host { display: block; font-family: inherit; contain: content; }

Two things almost every component needs:

  • Custom elements default to display: inline. If the component is block-level, set it in :host.
  • Inheritable properties like font-family do not automatically pass into form elements or buttons in shadow DOM. Setting font-family: inherit in :host explicitly pulls the page font down.

:host accepts a selector argument to apply styles conditionally based on the component's own attributes:

CSS
:host([disabled]) { opacity: 0.5; pointer-events: none; cursor: not-allowed; } :host([size="large"]) { padding: 1.5rem 2rem; font-size: 1.125rem; }

This is the internal implementation of pre-defined themes. When a consumer sets theme="warn" on the element, :host([theme="warn"]) fires inside and applies the right internal variable values.

:host-context()

:host-context() is :host with ancestor awareness. It applies styles to the host element based on a selector matching somewhere up the DOM tree -- context the component cannot know about at authoring time.

CSS
/* Adjust spacing when placed inside a .toolbar */ :host-context(.toolbar) { padding: 0.25rem 0.5rem; border-radius: 4px; } /* Adjust appearance when inside a dark container */ :host-context([data-theme="dark"]) { --internal-bg: rgb(30, 30, 40); --internal-border: rgb(60, 60, 80); }

A button component does not know whether it will be placed in a header, a card, or a form. :host-context() lets it respond to that placement at render time rather than requiring the consumer to remember a context-specific class.

Browser support is incomplete. :host-context() is in Chromium-based browsers and was never fully standardized. Treat it as a progressive enhancement -- useful where it works, not a required part of a component's styling strategy.

::slotted()

::slotted() styles slotted content from inside the shadow root. When a consumer passes in light DOM content through a <slot>, ::slotted() is how the component author can apply styles to what was passed in.

CSS
/* Style any element slotted into this component */ ::slotted(*) { margin-top: 0; } /* Style specifically-typed slotted content */ ::slotted(p) { line-height: 1.7; } ::slotted([slot="header"]) { font-weight: 600; font-size: 1.1rem; }

The constraint: ::slotted() only reaches one level deep. If a consumer passes in <div><p>text</p></div>, ::slotted(p) will not match the paragraph -- only the <div> is directly slotted.

This is the same "one level" rule that appears with ::part(). Both give you one step in from the boundary, no further.

::slotted(*) is the common workaround for applying baseline resets to whatever comes in, regardless of element type.

:not(:defined)

:defined matches any element that has been registered with customElements.define(). Its inverse, :not(:defined), matches elements whose JavaScript definition has not yet loaded or fired.

CSS
/* Hide until registered */ slow-loader:not(:defined) { opacity: 0; height: 300px; background: rgb(40, 40, 50); } /* Fade in once registered */ slow-loader { transition: opacity 0.3s ease; }

This controls the flash of unstyled content -- the brief moment between when the browser parses the tag and when the component's JavaScript runs.

You can go further than hiding. A skeleton screen pattern uses :not(:defined) to show animated placeholder content -- a shimmer or pulse -- that disappears once the component registers and renders real content. The placeholder has height, preventing layout shift. The transition is clean.

You can also apply this to all custom elements at once:

CSS
:not(:defined) { visibility: hidden; }

That hides every unregistered custom element on the page until its JavaScript arrives. Use with care on pages that load components lazily.

adoptedStyleSheets

Writing styles as a <style> tag inside the component constructor works. For large components or shared stylesheets, the more ergonomic approach is adoptedStyleSheets -- attaching a constructed stylesheet object to a shadow root from JavaScript.

One naming collision worth knowing: CSS module scripts are not the same thing as CSS Modules. CSS Modules is the webpack/bundler feature that generates scoped class names (.button_abc123). CSS module scripts is a native browser API that lets you import a .css file as a JavaScript object using assert { type: 'css' }. Same-ish words, completely different things. Shadow DOM makes the bundler-style scoped class names unnecessary -- the shadow boundary handles scoping. CSS module scripts are the native equivalent for loading a stylesheet without a <style> tag.

JavaScript
// Import a CSS file as a constructed stylesheet (CSS module script) import sheet from './alert.css' assert { type: 'css' }; class CustomAlert extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.adoptedStyleSheets = [sheet]; } }

The stylesheet object is created once and can be shared across every instance of the component -- one object in memory, applied to hundreds of shadow roots. This is more efficient than injecting a <style> tag into each instance.

CSS module scripts (assert { type: 'css' }) are not supported in all browsers as of mid-2026. Chromium supports them; Firefox and Safari support is still maturing. For production components, inline the styles or use a build tool that compiles the import to a supported form.

Lit resolves this ergonomically:

JavaScript
import { LitElement, css } from 'lit'; class CustomAlert extends LitElement { static styles = css` :host { display: block; } ::slotted(p) { margin: 0; line-height: 1.7; } :host([theme="warn"]) { --bg: rgb(80, 50, 10); } `; }

Lit compiles static styles to an adoptedStyleSheets call internally. The authoring experience resembles CSS-in-JS; the output is the performant shadow root pattern.

The next step in the web components journey moves from styling to writing the full component -- the custom element class, lifecycle callbacks, and attribute-driven state.

The Essentials

  1. :host styles the custom element itself from inside the shadow root. Always set display: block here -- custom elements default to inline.
  2. :host([attribute]) conditionally applies styles based on the host element's attributes. This is how pre-defined themes are implemented internally.
  3. :host-context(selector) applies styles based on where the component appears in the DOM. Chromium-only -- use as progressive enhancement.
  4. ::slotted() styles slotted light DOM content from inside the component. It only reaches one level deep. ::slotted(*) targets all directly-slotted elements.
  5. :not(:defined) matches a custom element before its JavaScript registers. Use it for fallback heights, skeleton screens, or fade-in transitions.
  6. adoptedStyleSheets attaches a constructed stylesheet to a shadow root without a <style> tag. Lit's static styles handles this automatically.

Further Reading and Watching