Forwarding Parts with exportparts: Reaching Through Nested Components
::part() reaches exactly one boundary. A page can style my-card::part(header) because the card exposed that part, and it cannot style anything inside a component the card uses, because that component’s parts were exposed to the card and stop there. Parts do not propagate, and a nested design system hits the wall immediately: a <data-table> built from <table-row> elements has no way to let its consumers restyle a cell, even though every layer exposed the part it should.
exportparts is the forwarding mechanism. It re-publishes an inner component’s parts through the outer component’s own boundary, optionally renaming them, so a consumer sees one flat vocabulary regardless of how many layers produced it. This deep dive belongs to Part & Slotted Selectors inside Styling, Theming & CSS Encapsulation.
Problem Statement: The Part That Stops One Layer Short
class TableRow extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>[part~="cell"] { padding: 0.5rem 0.75rem; }</style>
<div part="row">
<span part="cell label"><slot name="label"></slot></span>
<span part="cell value"><slot name="value"></slot></span>
</div>`;
}
}
customElements.define('table-row', TableRow);
class DataTable extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>[part~="body"] { display: grid; }</style>
<div part="body">
<table-row><span slot="label">CPU</span><span slot="value">42%</span></table-row>
<table-row><span slot="label">Memory</span><span slot="value">1.2 GB</span></table-row>
</div>`;
}
}
customElements.define('data-table', DataTable);
/* Consumer stylesheet */
data-table::part(body) { gap: 0.25rem; } /* works */
data-table::part(cell) { font-variant-numeric: tabular-nums; } /* matches nothing */
The cell part exists, it is correctly declared, and data-table::part(cell) selects nothing. ::part() matches parts exposed by the element it is applied to, and data-table never exposed cell — table-row did, to data-table, one layer in. The consumer’s only options are to give up, to fork the table, or to ask for a CSS custom property for every single visual decision.
Root-Cause Analysis
CSS Shadow Parts defines two attributes with complementary jobs. part names an element inside a shadow tree so the element’s host can be styled through ::part() from the tree above. exportparts, placed on a host element inside a shadow tree, forwards that inner host’s parts outward to the next tree up.
The forwarding syntax is a comma-separated map, each entry either a bare name or inner: outer:
<table-row exportparts="cell, row: table-row-container"></table-row>
That re-publishes table-row’s cell part under the same name and its row part under the name table-row-container, both now visible to whoever can style data-table.
Four rules decide whether a forward actually works.
Forwarding is per hop. Three nested layers need exportparts on each intermediate host. There is no wildcard that forwards everything through every layer, and no recursive form — the specification deliberately requires each layer to opt in, so a component cannot accidentally leak a dependency’s internals it never intended to publish.
Only listed parts are forwarded. exportparts="cell" publishes cell and nothing else. A part not named simply stays private, which is the mechanism that lets a component expose a curated surface rather than everything it happens to contain.
Names live in a flat namespace per boundary. Two different inner components both exposing header collide when forwarded unrenamed, and the later declaration does not “win” — both match, so a consumer rule hits both. Renaming on forward is how a design system keeps the outer vocabulary unambiguous.
Multi-part elements forward per name. An element with part="cell label" has two part names, each forwardable independently, so exportparts="label" publishes only that one.
exportparts on the wrong element. The attribute belongs on the inner host element, inside the outer component's shadow tree — not on the outer component's own host, and not on the element carrying part. Putting it on the outer host is the most common mistake and it fails silently: the selector matches nothing, exactly as before, and nothing reports that the attribute was ignored.
There is a design consequence worth stating plainly. Forwarding makes a dependency’s part names part of your public API. If the inner component renames cell to data-cell in its next major version, a consumer’s stylesheet breaks even though they never depended on that component directly. Renaming on forward insulates you from that: exportparts="cell: table-cell" means the outer name is yours to keep stable regardless of what the dependency does.
Production-Safe Pattern
class TableRow extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: contents; }
[part~="row"] { display: contents; }
[part~="cell"] { padding: 0.5rem 0.75rem; }
</style>
<div part="row">
<span part="cell label"><slot name="label"></slot></span>
<span part="cell value"><slot name="value"></slot></span>
</div>`;
}
}
customElements.define('table-row', TableRow);
class DataTable extends HTMLElement {
static observedAttributes = ['rows'];
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; }
[part~="body"] { display: grid; grid-template-columns: max-content 1fr; }
</style>
<div part="body"></div>`;
}
connectedCallback() { this.#render(); }
attributeChangedCallback() { this.#render(); }
#render() {
const body = this.shadowRoot.querySelector('[part~="body"]');
const rows = JSON.parse(this.getAttribute('rows') ?? '[]');
body.replaceChildren(...rows.map(([label, value]) => {
const row = document.createElement('table-row');
// The forwarding declaration. It belongs on the INNER host, and every
// name is renamed so our public vocabulary is ours to keep stable.
row.setAttribute(
'exportparts',
'cell: table-cell, label: table-label, value: table-value, row: table-row'
);
row.innerHTML = `
<span slot="label">${label}</span>
<span slot="value">${value}</span>`;
return row;
}));
}
}
customElements.define('data-table', DataTable);
<data-table rows='[["CPU","42%"],["Memory","1.2 GB"]]'></data-table>
/* Consumer stylesheet: one flat vocabulary, two layers behind it. */
data-table::part(body) { gap: 0.25rem 1rem; }
data-table::part(table-cell) { font-variant-numeric: tabular-nums; }
data-table::part(table-label) { color: #46557a; }
data-table::part(table-value) { font-weight: 600; }
/* Composes with states and user-action pseudo-classes as usual. */
data-table:state(loading)::part(table-cell) { opacity: 0.5; }
data-table::part(table-value):hover { text-decoration: underline; }
Three properties are worth copying. Every forwarded name is renamed with a consistent prefix, so the public vocabulary is stable and collision-free even if the table later swaps table-row for a different implementation. The forwarding attribute is set on the inner host at construction, so it cannot be forgotten for some rows and not others. And the outer component’s own parts (body) sit in the same flat namespace as the forwarded ones, which is what makes the consumer’s experience uniform — they should not be able to tell which names came from where.
For a component library, generate the exportparts string from the same declaration that documents the part vocabulary, so the forwarded set and the documented set cannot drift — the custom elements manifest records cssParts for exactly this reason.
Verification
const table = document.querySelector('data-table');
const row = table.shadowRoot.querySelector('table-row');
const cell = row.shadowRoot.querySelector('[part~="cell"]');
// 1. The forwarding attribute is on the inner host, not the outer one.
console.assert(row.hasAttribute('exportparts'), 'inner host carries exportparts');
console.assert(!table.hasAttribute('exportparts'), 'outer host must not carry it');
// 2. The forwarded name is selectable from the page.
const sheet = new CSSStyleSheet();
sheet.replaceSync('data-table::part(table-cell) { outline: 2px solid rgb(0, 128, 0); }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
console.assert(
getComputedStyle(cell).outlineColor === 'rgb(0, 128, 0)',
'forwarded part is reachable from the page'
);
// 3. The ORIGINAL inner name is not reachable — the rename is a real boundary.
const probe = new CSSStyleSheet();
probe.replaceSync('data-table::part(cell) { outline-width: 9px; }');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, probe];
console.assert(
getComputedStyle(cell).outlineWidth !== '9px',
'un-forwarded original name stays private'
);
// 4. A part that was never forwarded stays private.
const rowBox = row.shadowRoot.querySelector('[part~="row"]');
console.assert(rowBox !== null, 'the row part exists internally');
Assertion three is the one that proves the rename is doing work: if the original name still matches, the component is exposing a dependency’s vocabulary and has inherited that dependency’s naming stability. In DevTools, selecting the deeply nested element and reading its Styles pane shows the consumer rule listed under the page stylesheet — if it is absent, the forward did not reach that layer, and walking outward through the hosts shows exactly which exportparts is missing.
When to Use vs When to Avoid
| Situation | Approach |
|---|---|
| Composite component built from other components | exportparts on each inner host, renamed |
| Same part name from two different inner components | Rename both on forward; unrenamed names collide |
| Deeply nested layers | One exportparts per hop — there is no recursion |
| Only a colour or spacing needs to vary | A CSS custom property is simpler and inherits freely |
| Inner component is a third-party dependency | Forward with a rename, always — insulate your API |
| Internal structure that should stay private | Do not forward; that is the default |
| Consumer needs to reach inside a part | Not possible — expose that element as its own part |
The custom-property row is the judgement call that comes up most. A part gives consumers arbitrary CSS on a specific element and permanently commits you to that element existing; a custom property gives them one value and commits you to nothing structural. Prefer properties for theming, parts for layout and structural overrides — the split developed in implementing design tokens with CSS custom properties.
Frequently Asked Questions
Where exactly does the exportparts attribute go?
On the inner host element, inside the outer component’s shadow tree. It does not go on the outer component’s own host and not on the element carrying part. Putting it in the wrong place fails silently.
Is there a way to forward every part at once?
No. Each part must be named, and each boundary needs its own declaration. That is deliberate: it stops a component from leaking a dependency’s internals it never meant to publish.
Should I rename parts when forwarding?
Almost always. Renaming makes the outer vocabulary yours, so a dependency’s future rename is not a breaking change for your consumers, and it resolves collisions when two inner components use the same part name.
Can a consumer style something inside a forwarded part?
No. A pseudo-element ends the selector’s reach, so ::part(table-cell) span matches nothing. If consumers need that element, expose it as its own part and forward it too.
Related
- Part & Slotted Selectors — the parent topic on the part vocabulary.
- Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Combining Custom States with Part Selectors — conditioning forwarded parts on component state.
- Styling Nested Slots with Slotted Combinators — the projection side of the same boundary.
- Types & the Custom Elements Manifest — recording the part vocabulary as published API.