skip to content

In a shadow-DOM component, what does putting `part="label"` on an internal element let the page's CSS do, and what can the page still not do through `::part()`?

level: middleimportance: must knowfreq 48%

answer

  1. name it, then style it from outside
  2. element-level, not value-level
  3. nothing after a part's own box
  4. non-structural pseudo-classes only
  5. nesting needs explicit forwarding

basics

~20 s

A part name exposes that one element to outside CSS: the page writes my-el::part(label) { ... } and may set any property on it. The page still cannot select the part's descendants, cannot reach unexposed elements, and cannot see parts of nested shadow roots unless they are re-exported.

solid answer

~40 s

`part="label"` publishes one internal element under a name, and the page then styles it with the `::part()` pseudo-element: `my-el::part(label) { color: crimson; text-transform: uppercase }`. Unlike custom properties, this is *element*-level — the consumer can apply any property they like, not just values the author pre-wired. The fences are: you cannot descend past a part, so `my-el::part(label) span` matches nothing; you can only append non-structural pseudo-classes and pseudo-elements, as in `::part(label):hover` or `::part(label)::after`; anything without a `part` attribute stays invisible; and parts do **not** tunnel through nested shadow roots — an inner component's parts are only reachable if that inner element carries `exportparts="inner-name"`, optionally renaming with `exportparts="inner:outer"`. For normal declarations the page's `::part()` rules beat the component's own shadow styles.

code

html · 20 lines
html
<my-card></my-card>

<style>
  /* styles the exposed element itself */
  my-card::part(title) { font-variant: small-caps; }
  my-card::part(title)::after { content: ' *'; }
  /* forwarded from the nested <my-button> inside my-card */
  my-card::part(action-label) { letter-spacing: 0.08em; }
</style>

<script>
  customElements.define('my-card', class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' }).innerHTML = `
        <h2 part="title">Card</h2>
        <my-button part="action" exportparts="label: action-label"></my-button>`;
    }
  });
</script>

go deeper

for a junior

Know that part="name" on an internal element plus my-el::part(name) in the page is how outside CSS styles a component's internals, and that an element without a part attribute cannot be styled from outside.

for a middle

Explain that a part exposes one element rather than one value, that you cannot descend past it, and that only non-structural pseudo-classes may follow. Mention exportparts for nested shadow roots.

for a senior

Show that you understand the cascade rule — outer tree wins for normal declarations, !important inverts it — and be ready to diagnose consumer reports where a ::part override is silently losing or where a nested part was never forwarded.

for a principal

Own the exposure policy: which anchors get part names, how you name for role rather than markup, and how you cope with having no deprecation signal when a part name is removed. Weigh the refactoring tax a broad part surface imposes on the library.

## What a part is The `part` attribute marks an element inside a shadow tree as publicly styleable and gives it a name. Names are space-separated, so one element can carry several (`part="label required"`), and several elements can share a name (`part="cell"` on every cell), in which case one rule styles them all. From outside, the `::part()` pseudo-element selects them: ```html <!-- inside the shadow root --> <button part="control"><span part="label">Save</span></button> ``` ```css /* page stylesheet */ my-button::part(control) { border-radius: 999px; } my-button::part(label) { text-transform: uppercase; } my-button::part(control):hover { background: #eee; } ``` The part name is what the consumer depends on, not the tag or class — the author can swap `<span>` for `<b>` freely, as long as the name stays. ## How it differs from a custom property A custom property is a **value** channel: the author decides in advance that `--btn-bg` will drive `background`, and nothing else. A part is an **element** channel: the consumer gets the whole element and can set any property on it — layout, pseudo-elements, transitions, properties nobody anticipated. That makes parts far more powerful and far more expensive to keep stable: once a consumer writes `::part(label) { position: absolute }`, the part's box behaviour is effectively public API, and you cannot restructure around it without breaking them. ## The fences **No descending.** `::part()` is a pseudo-element, and you cannot select descendants of a pseudo-element. `my-el::part(control) svg`, `my-el::part(control) > span` and `my-el::part(control) *` all match nothing. If a consumer needs the icon inside the control, the author must give the icon its own part. **Only non-structural pseudo-classes may follow.** `::part(control):hover`, `:focus-visible`, `:disabled` are fine. Tree-structural selection such as `::part(cell):nth-child(2)` is not — the outside has no visibility into the part's position in its own tree. A component that wants that must expose it, for instance by adding a second part name to the relevant element from script. **A pseudo-element may follow.** `::part(label)::after { content: ' →' }` works, which is a common way for consumers to add adornments the author never planned. **No part, no access.** Everything unnamed remains private. This is the design: exposing a part is a deliberate act. **Parts do not nest automatically.** If `<my-card>`'s shadow tree contains `<my-button part="action">`, the page can style `my-card::part(action)` — that styles the `<my-button>` host element. But the parts *inside* `my-button`'s own shadow root are invisible to the page, because a part name is only visible one level up. To forward them, the inner element carries `exportparts`: ```html <!-- inside my-card's shadow root --> <my-button part="action" exportparts="control, label: action-label"></my-button> ``` Now `my-card::part(control)` and `my-card::part(action-label)` both work, the second showing the rename syntax `inner-name: outer-name`. Forwarding is explicit at every level, so a three-deep component tree needs `exportparts` at each hop — which is exactly the friction that discourages deep part surfaces. ## Cascade and specificity When the page's `::part()` rule and the component's own shadow rule both target the element, the **shadow cascade order** applies: for normal declarations, declarations from the outer tree win regardless of specificity, so the consumer's `::part()` rule beats the component's internal styling. `!important` reverses the order — an `!important` declaration inside the shadow tree beats an `!important` one outside — which is how an author locks a property that must not be overridden. Note that specificity is only compared within a tree; across trees, tree order decides first. ## Choosing what to expose Good parts are structural anchors that will survive refactoring: the control, the label, the panel, a row. Bad parts are incidental wrappers created for today's layout. Because parts have no versioning story, teams usually expose few of them, name them for role rather than for markup, and document them alongside the token list. When a consumer's need is a single value, a custom property is the cheaper answer; when it is unbounded, a part is the only answer the platform offers short of abandoning the shadow root.

  • A consumer writes `my-el::part(control) svg { fill: red }` and nothing happens. Why?
    `::part()` is a pseudo-element, and you cannot select descendants of a pseudo-element — the rule matches nothing at all. The part gives access to that element only. If the icon must be styleable, the component author has to put a `part` on the `<svg>` itself, or expose a `--icon-fill` custom property that the internal rule consumes.
  • The page's `::part()` rule and the component's own internal rule set the same property. Which wins?
    For normal declarations the outer tree wins, whatever the specificity — that is the shadow cascade order, and it is what makes parts useful as an override mechanism. `!important` flips it: an `!important` declaration inside the shadow tree beats an `!important` one from the page, which is how an author pins a property they refuse to let consumers change.
  • How would you let consumers style only the selected row of an internal list?
    Add a second part name from script when state changes — `row.setAttribute('part', selected ? 'row row-selected' : 'row')` — so the page can write `::part(row-selected)`. Structural selection like `::part(row):nth-child(2)` is not available from outside, so state has to be published as part names.

saying these in an interview costs you the question

  • ::part lets you select inside the part's subtree
  • Any element in the shadow root is reachable via ::part
  • Nested components' parts are automatically visible
  • Specificity decides between page and shadow rules
  • ::part and custom properties do the same job

context