::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.
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:
::part()exposes specific internal nodes to external stylesheets while preserving encapsulation for the rest of the tree.::slotted()targets light DOM content projected into<slot>elements, enabling layout-aware styling of distributed nodes.
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:
- Inspect the
<slot>element in DevTools. - Check the
assignedNodes()array via console:document.querySelector('slot[name="title"]').assignedNodes(). - If empty, fallback renders. If populated, verify
::slotted()matches only top-level assigned nodes. Useslotchangeto 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:
- Group
::partselectors by shared properties. - Avoid
::partinside media queries unless necessary. - Use
contain: layout styleon shadow hosts to isolate recalculation scope.
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.
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.
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.
Related
- Styling Nested Slots with ::slotted Combinators — why combinators fail past
::slottedand the patterns that work instead. - CSS Variables & Custom Properties — pair structural selectors with token-driven values.
- Scoped Styles & Constructable Stylesheets — inject
::partrules efficiently viaadoptedStyleSheets. - Theme Inheritance & Light DOM Styling — weigh inheritance chains against encapsulation overhead.
- Styling, Theming & CSS Encapsulation — the parent section on encapsulation primitives.