::part and ::slotted Selectors

1. Cross-Boundary Styling Architecture

Component styling in modern web architecture requires precise traversal across encapsulation boundaries without violating isolation guarantees. The CSS Scoping Module Level 1 specification defines two targeted pseudo-elements for this exact purpose, forming a critical bridge within the broader Styling, Theming & CSS Encapsulation paradigm. This section establishes the architectural intent, mapping consumer-driven styling needs to component-internal exposure points.

Two gateways through the shadow boundary A page stylesheet reaches an exposed part across the shadow boundary, while the component stylesheet uses slotted to style projected content without leaving its own tree. shadow boundary page stylesheet ds-button::part(label) outside the shadow tree crosses the boundary part="label" exposed shadow node never leaves the tree component stylesheet ::slotted([slot=title]) projected child assigned to a <slot> The page also styles projected content with its ordinary rules — that content never left its tree.

Encapsulation Boundaries & Selector Scope

Shadow DOM enforces strict style isolation by default. Standard CSS selectors cannot penetrate the shadow tree, preventing accidental style collisions but also blocking legitimate customization. ::part and ::slotted act as controlled gateways:

Shadow DOM Specification Alignment

Both selectors align with the WHATWG DOM Standard and CSS Scoping Module Level 1. They operate at the computed style phase, meaning they do not mutate the DOM tree but alter the cascade resolution order. Architects must treat them as explicit API contracts rather than implementation details. Overexposing internal nodes via ::part or relying on deep ::slotted traversal violates the single-responsibility principle of component boundaries.


2. ::part Selector: Implementation & Compliance

The ::part() pseudo-element enables external stylesheets to target internal Shadow DOM nodes explicitly marked with the part attribute. Implementation requires strict adherence to naming conventions that prevent collision and maintain semantic clarity. Unlike global selectors, ::part respects encapsulation while allowing controlled style injection. Architects must balance exposure granularity with maintainability, ensuring that only stable, public-facing nodes are exposed to consumer stylesheets.

Attribute Mapping & Naming Conventions

Parts must be declared declaratively in HTML or dynamically via ElementInternals/setAttribute. Use kebab-case prefixes to namespace parts within a design system.

<!-- Component Template -->
<template id="ds-button-tpl">
  <button class="ds-button" part="host">
    <span part="icon" slot="icon"></span>
    <span part="label"><slot></slot></span>
    <span part="badge" hidden></span>
  </button>
</template>
/* Consumer Stylesheet */
ds-button::part(host) {
  background: var(--ds-surface-primary);
  border-radius: var(--ds-radius-md);
}

ds-button::part(label) {
  font-weight: 600;
  letter-spacing: 0.02em;
}

Specificity & Cascade Resolution

::part() carries a specificity of 0,0,0,1 (one pseudo-element). It does not inherit specificity from the host element. When multiple ::part rules target the same node, standard cascade rules apply: specificity → source order → importance.

Pitfall: Applying !important to ::part rules breaks consumer override capabilities. Reserve !important for internal fallbacks only.

Production Pattern: Explicit Exposure

Use ElementInternals (ES2022+) to programmatically manage part exposure without direct DOM manipulation:

class DSButton extends HTMLElement {
  static #observedAttributes = ['variant'];
  static get observedAttributes() {
    return [...DSButton.#observedAttributes];
  }

  #internals = this.attachInternals();
  #shadow = this.attachShadow({ mode: 'open' });

  constructor() {
    super();
    const tpl = document.getElementById('ds-button-tpl');
    this.#shadow.appendChild(tpl.content.cloneNode(true));
  }

  attributeChangedCallback(name, _, newVal) {
    if (name === 'variant') {
      // Dynamically toggle part visibility/exposure
      const host = this.#shadow.querySelector('[part="host"]');
      host?.toggleAttribute('data-variant', !!newVal);
    }
  }
}
customElements.define('ds-button', DSButton);

Debugging Step: Open Chrome DevTools → Elements → Shadow DOM. Right-click a node → Toggle part attribute. Verify computed styles update without triggering full layout recalculation.


3. ::slotted Selector: Projection & Distribution

The ::slotted() pseudo-element targets distributed nodes projected into <slot> elements, operating exclusively on direct children of the slot. This constraint requires deliberate DOM structuring from component consumers. When dealing with deeply nested consumer markup, developers must reference Styling Nested Slots with ::slotted Combinators to understand flattening behavior, combinator limitations, and fallback rendering strategies. Proper implementation prevents unintended style leakage and ensures predictable layout composition.

Slot Flattening & Direct-Child Constraints

::slotted() only matches nodes that are direct children of the <slot> in the light DOM. It does not traverse into nested elements.

/* ✅ Valid: Targets direct slot children */
::slotted(span) {
  color: var(--ds-text-primary);
}

/* ❌ Invalid: Will NOT match nested children */
::slotted(div > span) {
  color: red;
}

Light DOM Integration Patterns

Enforce consumer markup contracts via slotchange event listeners and fallback validation.

<!-- Consumer Markup -->
<my-card>
  <h2 slot="title">Dashboard</h2>
  <p slot="content">Analytics overview</p>
</my-card>
/* Component Internal Styles */
::slotted([slot='title']) {
  margin: 0;
  font-size: var(--ds-font-xl);
}

::slotted([slot='content']) {
  line-height: 1.5;
  padding: var(--ds-space-md) 0;
}

Fallback Content Handling

Slots render fallback content when no light DOM is provided. ::slotted() does not apply to fallback nodes. Style fallbacks directly in the component template.

Debugging Step:

  1. Inspect the <slot> element in DevTools.
  2. Check the assignedNodes() array via console: document.querySelector('slot[name="title"]').assignedNodes().
  3. If empty, fallback renders. If populated, verify ::slotted() matches only top-level assigned nodes. Use slotchange to log distribution lifecycle.

4. Theme Integration & Token-Driven Workflows

Cross-boundary selectors handle structural targeting, but value propagation relies on CSS custom properties. Integrating ::part and ::slotted with CSS Variables & Custom Properties creates a decoupled theming layer where tokens drive visuals and pseudo-elements drive layout. This separation enables single-intent workflows: selectors define where styles apply, while variables define what styles apply. Architects should enforce strict token naming conventions and boundary-aware scoping to prevent cascade collisions.

Custom Properties as Value Carriers

Custom properties inherit across shadow boundaries by default, making them ideal for token propagation.

/* Global Theme */
:root {
  --ds-color-accent: #0055ff;
  --ds-radius-pill: 9999px;
}

/* Component Internal */
::part(host) {
  background: var(--ds-color-accent);
  border-radius: var(--ds-radius-pill);
}

/* Consumer Override */
ds-button {
  --ds-color-accent: #ff4400;
}

Boundary-Aware Token Propagation

To prevent token leakage, scope variables to the host element and explicitly inherit them internally.

:host {
  --_internal-spacing: var(--ds-spacing, 1rem);
}

::part(container) {
  padding: var(--_internal-spacing);
}

Design System API Contracts

Document exposed parts and required tokens in a machine-readable format (e.g., JSON schema or Web Component Manifest). Enforce versioning to avoid breaking consumer styles when parts are renamed or removed.

Pitfall: Relying on ::part for layout shifts instead of custom properties causes layout thrashing. Use ::part for structural hooks (e.g., ::part(wrapper)), and custom properties for dimensions, colors, and typography.


5. Performance Optimization & Production Tradeoffs

Overusing cross-boundary selectors introduces measurable performance overhead due to style recalculation and layout invalidation. Production implementations must prioritize ::part for stable internal nodes and reserve ::slotted for explicit projection points. When combined with Theme Inheritance & Light DOM Styling, teams must evaluate inheritance chains against encapsulation overhead to maintain 60fps rendering pipelines. Tradeoffs include increased CSS bundle size versus reduced JavaScript styling logic, requiring careful profiling in real-world environments.

Style Recalculation & Layout Thrashing

Each ::part or ::slotted rule forces the browser to traverse the shadow tree during style resolution. Excessive usage increases Recalculate Style time in the rendering pipeline.

Optimization Technique:

Selector Specificity vs. Maintainability

High-specificity ::part chains (::part(wrapper) > ::part(inner)) are invalid per spec. ::part only accepts a single identifier. Flatten your part hierarchy:

/* ❌ Invalid */
::part(wrapper)::part(inner) { ... }

/* ✅ Valid */
::part(wrapper-inner) { ... }

Constructable Stylesheet Integration

Leverage adoptedStyleSheets for framework-agnostic, high-performance style injection.

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
  :host { display: block; }
  ::part(host) { transition: transform 0.2s ease; }
`);

class OptimizedComponent extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.adoptedStyleSheets = [sheet];
    shadow.innerHTML = `<div part="host"><slot></slot></div>`;
  }
}

Debugging Step: Open DevTools → Performance → Record. Filter by Layout and Recalculate Style. If ::part rules trigger frequent invalidations, move static styles to adoptedStyleSheets and isolate dynamic tokens to CSS variables.


6. Testing Strategies & Single-Intent Developer Workflows

Automated testing for cross-boundary styles requires environments that accurately simulate Shadow DOM projection and slot distribution. Single-intent developer workflows dictate that each selector serves one explicit purpose: ::part for internal component customization, ::slotted for consumer-provided content styling, and custom properties for value injection. Implement computed style assertions, snapshot testing, and visual regression pipelines to validate boundary interactions. CI/CD integration should enforce strict linting rules for selector specificity and part attribute exposure.

Headless Browser Shadow DOM Simulation

Playwright and Puppeteer natively support shadow DOM traversal. Avoid DOM-parsing workarounds; use native selectors.

// Playwright Test
import { test, expect } from '@playwright/test';

test('::part styling applies correctly', async ({ page }) => {
  await page.setContent(`
  <my-component>
  <span slot="label">Test</span>
  </my-component>
  <style>
  my-component::part(host) { background: red; }
  my-component::slotted([slot="label"]) { color: white; }
  </style>
  `);

  const host = page.locator('my-component').locator('::part(host)');
  await expect(host).toHaveCSS('background-color', 'rgb(255, 0, 0)');
});

Computed Style Assertions

Validate that tokens resolve correctly across boundaries.

async function assertTokenResolution(page, selector, token, expected) {
  const value = await page.evaluate(
    (sel, tok) => {
      const el = document.querySelector(sel);
      return getComputedStyle(el).getPropertyValue(tok).trim();
    },
    selector,
    token
  );

  expect(value).toBe(expected);
}

Visual Regression & Snapshot Pipelines

Integrate Percy or Chromatic with CI/CD. Configure snapshot diffing to ignore dynamic tokens (e.g., --ds-theme-mode) while asserting structural ::part boundaries.

CI/CD Linting Rule (stylelint):

{
  "rules": {
    "selector-pseudo-element-no-unknown": [true, { "ignorePseudoElements": ["part", "slotted"] }],
    "selector-max-specificity": "0,2,0",
    "declaration-no-important": true
  }
}

Pitfall: Snapshot tests fail when ::slotted content changes height/width. Use contain: size on slotted containers during testing to stabilize layout dimensions.

By adhering to these patterns, teams can build scalable, framework-agnostic component libraries that balance encapsulation with consumer flexibility, ensuring predictable styling across complex application architectures.

How far each cross-boundary selector reaches Part selectors reach one named element one boundary in, slotted selectors reach the top level of projected content, and custom properties reach every depth by inheritance. Reach, from a consumer stylesheet ::part(name) one element, one hop cannot descend past it; needs exportparts per further hop ::slotted(sel) top level only never matches descendants of an assigned node --custom-property every depth, every nested tree, by inheritance a plain descendant selector stops at the boundary; matches nothing inside The design consequence Tokens for values, parts for structure, and nothing at all for what should stay private.

Choosing Between a Part, a Token, and Nothing

Every element inside a shadow tree is one of three things to a consumer: themeable through a token, restylable through a part, or private. Choosing deliberately is the whole design exercise, because both hooks are permanent once published.

A custom property is the lighter promise. It commits to a value existing, not to an element existing, so the component can restructure freely as long as the token still means something. It inherits, so it reaches every depth including nested components, and it costs nothing to add. This should be the default for colour, spacing, radius, typography, and any single value a consumer might reasonably want to change.

A part is the heavier promise. It grants arbitrary CSS on a specific element, which means that element must keep existing, keep that role, and keep being reachable for as long as the part name is published. In exchange, consumers can do things a token cannot express — change a layout, add a pseudo-element, apply a transform.

Private is the default. An element with no part attribute and no token is one the component can delete tomorrow. Most internal wrappers should stay that way; exposing them “just in case” converts implementation detail into API with no request behind it.

class MediaCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host {
          display: block;
          /* Tokens: values, with defaults so the component works unthemed. */
          --card-radius: var(--wfc-card-radius, var(--wfc-radius, 10px));
          --card-inset: var(--wfc-card-inset, var(--wfc-space-inset-md, 1rem));
          border-radius: var(--card-radius);
        }

        /* Private: no part, no token. Free to change. */
        .frame { display: grid; gap: 0.5rem; }

        /* Public: named for its ROLE, so the name survives a redesign. */
        [part~="header"] { padding: var(--card-inset); font-weight: 700; }
        [part~="body"]   { padding: var(--card-inset); }

        /* Projection: the consumer owns these nodes; style defaults only. */
        ::slotted(h3) { margin: 0; font-size: 1.05rem; }
      </style>
      <div class="frame">
        <div part="header"><slot name="title"></slot></div>
        <div part="body"><slot></slot></div>
      </div>`;
  }
}
customElements.define('media-card', MediaCard);

Debugging Pitfall: ::slotted() matches only the top-level assigned node, so ::slotted(div p) and ::slotted(div) p both match nothing — the first is invalid as a compound argument, the second tries to descend past the boundary. It also loses ties to any light-DOM rule of equal specificity, because projected content is styled by the consumer’s document. Treat ::slotted() as a way to set defaults for projected content, never as a way to control it.

Naming Parts So the Names Survive

A part name is published API from the first release that ships it, and the names that break are the ones describing appearance or position rather than role. ::part(top-bar) is accurate until a redesign moves it; ::part(header) stays accurate through every visual change. ::part(blue-button) is wrong the moment the brand changes; ::part(action) is not.

Three conventions keep a vocabulary usable across versions.

Name the role, not the look or the location. header, body, footer, action, media, label, indicator — each describes what the element is for, which is the thing least likely to change.

Use multiple part names for orthogonal facets. The part attribute takes a space-separated list, so one element can be part="cell label" and be reachable both as a generic cell and as a specific label. That lets consumers style all cells uniformly and single out one, without the component publishing a combinatorial explosion of names.

Prefix names that will be forwarded. A part that travels outward through exportparts enters a flat namespace shared with every other forwarded name at that level, so cell from two different inner components collide. Renaming on forward — cell: table-cell — resolves it and simultaneously insulates your public name from the dependency’s internal one.

// Multiple names per element: generic and specific, from one attribute.
this.shadowRoot.innerHTML = `
  <div part="row">
    <span part="cell label"><slot name="label"></slot></span>
    <span part="cell value"><slot name="value"></slot></span>
  </div>`;
/* The consumer gets both granularities with no extra API from the component. */
my-row::part(cell)  { padding: 0.5rem 0.75rem; }
my-row::part(value) { font-variant-numeric: tabular-nums; font-weight: 600; }

Recording the vocabulary in the custom elements manifest through a @csspart annotation makes it documentation rather than folklore, and gives a generated reference that cannot describe a part the source does not declare.

Part names that survive a redesign versus names that do not Role-based names remain accurate through visual change, while names describing appearance or position become wrong the first time the design moves. A part name is published API from its first release names the role — survives header, body, footer action, media, label indicator, cell, value still accurate after any visual change names the look — breaks top-bar, left-column blue-button, small-text rounded-box, shadow-wrap wrong the first time the design moves

The same test applies to a part you are tempted to add on request: if the requester’s reason is “so I can make it blue”, the answer is usually a token rather than a part, and the part would have committed the component to an element whose only purpose was a colour. If the reason is “so I can change its layout in our dense table view”, a part is the right hook, because no token can express that.

Browser Compatibility

Feature Chromium Firefox Safari
::part() 73 72 13.1
part attribute with multiple names 73 72 13.1
exportparts 73 72 13.1
User-action pseudo-classes after ::part() 73 72 13.1
::slotted() 53 63 10.1

Both selectors have been interoperable since 2019 and need no fallback. The compatibility question that does come up is a different one: what a consumer should do when a component exposes neither a part nor a token for something they need to change. The answer is not a workaround — there is no supported way into a shadow tree from outside — it is a request to the component’s maintainers, and the fact that it is a request rather than a hack is exactly what makes the boundary worth having. A design system that treats those requests as signal rather than noise ends up with a hook vocabulary shaped by real use instead of by guesswork.

Frequently Asked Questions

Why does ::slotted() not style elements inside my projected content?

Because it matches only the top-level assigned node. Anything deeper belongs to the consumer’s tree and is styled by their document; a component cannot reach it, and should expose a token or a part instead of trying.

Should I expose a part or a custom property?

A property when a single value needs to change — colour, spacing, radius. A part when a consumer needs arbitrary CSS on a specific element. The property commits to nothing structural; the part commits that element to existing for as long as the name is published.

Can a consumer style inside a part?

No. A pseudo-element ends the selector’s reach, so ::part(body) span matches nothing. If consumers need that element, give it its own part attribute — there is no descendant escape hatch by design.

Do parts reach through nested components automatically?

No. Each intermediate host must forward them with exportparts, one hop per boundary, and renaming on forward is what keeps your public vocabulary stable when a dependency changes its internal names.

Can one element carry more than one part name?

Yes — the attribute takes a space-separated list, so part="cell label" is reachable both as a generic cell and as a specific label. That gives consumers two granularities from one element without the component publishing a combinatorial set of names.