Styling Web Components from Both Sides of the Shadow DOM

Web components offer style encapsulation through the shadow DOM, but that barrier can feel restrictive when you need to style internal elements from outside, or react to the surrounding page from inside. A handful of pseudo-elements and pseudo-classes exist specifically to bridge that gap: ::part, ::slotted, :defined, :host, and :host-context.

Piercing the Shadow DOM with ::part

Normally, all styles for content inside a shadow tree belong inside the component's own <style> tag within its <template>. But sometimes the style decision depends on information only available on the page where the component is used—for example, a zombie match profile that should highlight certain names based on match quality determined at runtime.

Since the <h2> lives inside the shadow DOM, an external selector like zombie-profile h2 won't reach it. The ::part pseudo-element solves this by letting you designate an element inside the shadow DOM as stylable from outside. Add a part attribute to that element in the template:

<template id="zprofiletemplate">
  <style>
    h2 {
      font-size: 3em;
      margin: 0 0 0.25em 0;
      line-height: 0.8;
    }
    /* other styles */
  </style>
  <div class="profile-wrapper">
    <div class="info">
      <h2 part="zname">
        <slot name="zombie-name">Zombie Bob</slot>
      </h2>
      <!-- other zombie profile info -->
    </div>
</template>

Then external styles can target it directly:

/* External stylesheet */
.high-match::part(zname) {
  color: blue;
}
.medium-match::part(zname) {
  color: navy;
}
.low-match::part(zname) {
  color: slategray;
}

There are constraints to keep in mind. You can't style the children of a part; you'd need to add a separate part attribute to each child element you want exposed. And while ::part works through one shadow boundary, it won't penetrate two nested shadow trees.

Forwarding Parts with exportparts

When you have a web component nested inside another web component's template, the inner component's parts aren't automatically visible to the outer page. The exportparts attribute on the nested component forwards those parts up through the shadow boundary:

<template id="zwatermarktemplate">
  <style>
    div {
    text-transform: uppercase;
      font-size: 2.1em;
      color: rgb(0 0 0 / 0.1);
      line-height: 0.75;
      letter-spacing: -5px;
    }
    span {
      color: rgb( 255 0 0 / 0.15);
    }
  </style>
  <div part="watermark">
    U n d y i n g  L o v e  U n d y i n g  L o v e  U n d y i n g  L o v e  <span part="copyright">©2 0 2 7 U n d y i n g  L o v e  U n L t d .</span>
  <!-- Repeat this a bunch of times so we can cover the background of the profile -->
  </div> 
</template>
<template id="zprofiletemplate">
  <style>
    ::part(watermark) {
      color: rgb( 0 0 255 / 0.1);
    }
    /* More styles */
  </style>
  <!-- zombie-profile markup -->
  <zombie-watermark exportparts="copyright"></zombie-watermark>
</template>
<style>
  /* External styles */
  ::part(copyright) {
    color: rgb( 0 100 0 / 0.125);
  }
</style>

Here, ::part(watermark) works from the <zombie-profile>'s template styles because it's only one shadow level up. But it won't work from the main page. Since the watermark component uses exportparts="copyright", the copyright part is exposed through both shadow trees, making ::part(copyright) available in external styles.

The attribute also supports renaming when forwarding:

<zombie-watermark exportparts="copyright: cpyear"></zombie-watermark>

Which allows external CSS to target the forwarded part under its new name:

/* Within zombie-profile's shadow DOM */

/* happy-face emoji */
::part(cpyear) { ... }

/* frowny-face emoji */
::part(copyright) { ... }

Structural pseudo-classes like :nth-child don't apply to parts, though state pseudo-classes such as :hover do work for animating exposed elements:

.high::part(name):hover {
  animation: highmatch 1s ease-in-out;
}

Reaching Slotted Content with ::slotted

While ::part reaches outward from inside a component, ::slotted works in the opposite direction. It's used within the component's own stylesheet to target elements that have been slotted into the component from the page. Define a slot in your template:

<zombie-profile>
  <img slot="profile-image" src="photo.jpg" /> 
  <!-- rest of the content -->
</zombie-profile>

Then style any element placed into that slot from the component's internal CSS:

::slotted(img) {
  width: 100%;
  max-width: 300px;
  height: auto;
  margin: 0 1em 0 0;
}

The selector must match an element directly—text nodes and children of the slotted element are outside ::slotted's reach. You can use ::slotted(*) to match any slotted element regardless of type.

Hiding Unregistered Components with :defined

Web components aren't rendered as their full selves until their class is registered via customElements.define(). Before that happens, they can appear as broken or unstyled markup. The :defined pseudo-class matches both built-in elements and registered custom elements, letting you hide components while they're still loading:

:not(:defined) {
  display: none;
}

Any <zombie-profile> that hasn't been defined yet gets display: none and appears automatically once registration completes.

Selecting the Custom Element Itself with :host

Styling the host element (the custom element tag itself) usually happens from the external page's CSS. But that breaks encapsulation. The :host pseudo-class styles the shadow root from inside the component's own stylesheet, making the component self-contained:

:host {
  width: calc(50% - 1em);
}

This eliminates the need for an extra wrapper <div> when the host itself can serve that role. Descendant selectors prefixed with :host can only reach elements inside the shadow DOM—not slotted content, unless you combine with ::slotted:

Showing the parts of the HTML that are relevant to the :host pseudo-element.

:host also accepts a parameter for conditional styling based on attributes or state of the host element:

:host(.high) {
  border: 2px solid blue;
}

Pseudo-classes like :host(:hover) and :host(:last-child) work as well.

Matching Ancestor Context with :host-context

:host only tells you about the host element itself. :host-context climbs the DOM tree to look at the host's ancestors. This is useful when a style decision depends on a class placed on a parent container rather than the component itself.

Given a structure where some profiles sit inside a .bestmatch wrapper and others in a .worstmatch wrapper:

<div class="profiles bestmatch">
  <zombie-profile class="high">
    <!-- etc. -->
  </zombie-profile>
  <!-- more profiles -->
</div>

<div class="profiles worstmatch">
  <zombie-profile class="medium">
    <!-- etc. -->
  </zombie-profile>
  <zombie-profile class="low">
    <!-- etc. -->
  </zombie-profile>
  <!-- more profiles -->
</div>

The :host pseudo-class alone can't style these differently, because the classes are on the parent divs, not on the <zombie-profile> elements

:host(.bestmatch) {
  background-color: #eef;
}
:host(.worstmatch) {
  background-color: #ddd;
}

:host-context looks past the host to find those ancestor classes and applies styles accordingly:

:host-context(.bestmatch) {
  background-color: #eef;
}
:host-context(.worstmatch) {
  background-color: #ddd;
}

Browser support is still incomplete—Firefox and Safari don't implement :host-context—so treat it as a progressive enhancement rather than a core layout strategy.

Pseudo-elements and pseudo-classes give web components two-way communication with their surroundings: ::part and ::slotted manage how external content and internal elements interact, while :defined, :host, and :host-context control the component's lifecycle representation and contextual styling. Together they handle most styling scenarios without breaking the shadow DOM's encapsulation benefits.