skip to content

You own a shared web-component library. How do you decide what each component exposes as CSS custom properties versus `::part()` names, and what does each choice cost you later?

level: principalimportance: should knowfreq 28%

answer

  1. values versus whole elements
  2. tokens keep markup refactorable
  3. parts freeze layout as API
  4. no deprecation signal either way
  5. expose state as extra part names

basics

~20 s

Custom properties expose values and keep internals refactorable; parts expose whole elements and let consumers apply anything, which freezes that element's structure and box behaviour as de-facto API. Default to tokens; grant parts only where needs are genuinely unbounded.

solid answer

~50 s

Treat both as public API and pick by how bounded the need is. A **custom property** is a value channel — the author decides which declaration it feeds — so the internal markup stays free to change, misuse is nearly impossible, and the cost is enumeration: every knob must be anticipated and documented. A **part** is an element channel: the consumer can set any property, including layout and pseudo-elements, so it solves needs you could not predict, but the moment someone writes `::part(label) { position: absolute }` that element's box, display type and place in the layout are frozen. My rule of thumb: tokens for colour, spacing, radius, typography; parts for a small set of durable structural anchors — control, label, panel, row — named for role, never for current markup. Neither has versioning or deprecation signals in the platform, so a rename silently breaks consumers; that argues for a deliberately small surface and written docs.

go deeper

for a junior

Know the two mechanisms exist and what each does: a custom property lets the page supply a value, a part lets the page style a whole internal element with any CSS it wants.

for a middle

Explain why tokens are the safer default — the author frames the decision and keeps internals refactorable — while a part hands over an element and whatever a consumer does with it becomes something you must not break.

for a senior

Show how these surfaces fail in production: silent breakage on rename, layout-dependent overrides breaking after a refactor, exportparts friction in composed components, and defaults declared on :host that block page-wide tokens.

for a principal

Own the policy: start closed and grant parts deliberately, name for role rather than markup, namespace and alias tokens to system tokens, and compensate for the platform's missing deprecation signal with docs, dual-named parts and visual regression coverage.

## Both are API, and the platform gives you no safety net Once a consumer ships CSS that references `--btn-bg` or `::part(label)`, that name is a contract. The platform provides no reflection (nobody can ask what a component supports), no warnings, and no deprecation path. Remove a token and the `var()` quietly falls back to the author's default; remove a part name and the `::part()` rule quietly matches nothing. Both failures are silent and visual, discovered by a designer, not by a build. That asymmetry — high blast radius, zero tooling — is the reason to be stingy with both. ## The two channels are not interchangeable **Custom properties are value-level and author-mediated.** The author writes `background: var(--btn-bg, #333)`, so the consumer supplies a *value* for a decision the author has already framed. The internal element could be a `<button>`, a `<div>`, three nested boxes — the token survives all of it. Misuse is bounded: the worst a consumer can do is pass an ugly colour. The cost is enumeration. Every themeable decision needs a token, and consumers routinely need one you did not think of, which turns into a stream of "please expose X" requests and an ever-growing token list. **Parts are element-level and consumer-mediated.** `part="label"` hands over an element; the consumer writes whatever CSS they like on it. That answers needs you cannot enumerate — a transition, a pseudo-element badge, an absolute-positioned overlay. The cost is that the element's *observable behaviour* becomes API in a way you never declared. You wanted to publish "there is a label"; what you actually published is its display type, its box model, its position in a flex line, its stacking. Refactoring the internals from a flex row to a grid can break consumers who positioned against the old layout, and you will not find out from tests. ## A working decision rule 1. **Is the need a value?** Colour, spacing, radius, font size, border width, shadow, duration → token. This covers the large majority of real theming requests. 2. **Is the need bounded but not a value?** A consumer wants uppercase labels, or a different `text-align` → still a token if the property list is short (`--label-transform`), because you keep the freedom to restructure. 3. **Is the need unbounded or unpredictable?** Adornments, animations, layout overrides on a specific node → part. 4. **Is the element durable?** Only expose a part on something that will still exist, by the same role, after a rewrite. The control, the label, the panel, a row. Never on a wrapper that exists because of today's flexbox choice. 5. **Would exposing it leak state?** If a consumer needs `::part(row)` styled differently when selected, publish state as an extra part name from script (`part="row row-selected"`) rather than expecting them to select structurally — outside CSS cannot use structural pseudo-classes after `::part()`. ## Costs that show up later **Nesting friction with parts.** A part name is visible exactly one level up. A component composed of components needs `exportparts` at every hop, and consumers of the outer component see a flat namespace assembled from several inner ones. Rename an inner part and every forwarding attribute in between needs updating. This is a real tax on composed libraries and a strong argument for exposing few parts on leaf components. **Token sprawl.** The opposite failure: fifty tokens per component, half of them undocumented, several meaning nearly the same thing. Namespacing (`--mylib-btn-bg`) prevents collisions with the consumer's own tokens but makes the list uglier; aliasing to system tokens (`var(--mylib-btn-bg, var(--mylib-color-surface))`) keeps global theming cheap while preserving per-component overrides. **Defaults in the wrong place.** A default declared as `:host { --btn-bg: #333 }` blocks any page-wide value from inheriting in, because a declaration beats inheritance. Library-wide, put defaults in `var()` fallbacks so global tokens work, and reserve `:host` declarations for values that genuinely must not be set from outside. **Locking with `!important`.** Shadow cascade order lets consumers' `::part()` rules beat internal ones for normal declarations. Where a property is load-bearing — the `display` a layout depends on, an `overflow` that prevents clipping bugs — an `!important` inside the shadow tree wins back control. Use it sparingly and document it, or you will field bug reports about overrides that do nothing. ## How I would run it Start closed: ship tokens only, and treat each part request as a design conversation rather than an automatic yes. Keep a single documented table per component listing tokens and parts, since the platform will not tell anyone. Add visual regression tests over a few consumer-style overrides so refactors that move a part's box are caught before release. And be explicit in the docs that markup *under* an exposed part is private — a claim that carries no enforcement, but sets the expectation that the library will not treat every internal reshuffle as a breaking change.

  • A consumer asks for a part on an inner wrapper so they can absolutely position an internal icon. How do you respond?
    Push back on the anchor, not the need. A wrapper that exists for today's layout is the worst thing to freeze. Either expose a part on the icon itself — a durable role that survives a restructure — or, if the request is really about spacing and colour, add tokens. If they truly need arbitrary CSS, grant the part on the durable element and state in the docs that its descendants are private.
  • How do you handle a component whose part surface must change?
    There is no platform deprecation, so run it in the library: keep the old part name alongside the new one for a release (an element can carry several space-separated names), announce it, and pair the change with visual regression coverage. Silent breakage is the default otherwise — a stale `::part()` rule simply matches nothing, with no console warning.
  • Why not skip the shadow root entirely so consumers can style anything?
    Then every internal class name and DOM shape is API, and consumers' global CSS collides with yours in both directions. The shadow boundary's value is that the default is private; tokens and parts are how you make the exceptions explicit. Giving up encapsulation to avoid designing a theming contract just relocates the maintenance cost and makes it unbounded.

saying these in an interview costs you the question

  • Expose a part for every element, let consumers decide
  • Custom properties and parts solve the same problem
  • Part names are internal, safe to rename anytime
  • More theming hooks always means a better component
  • Shadow cascade means the component always wins

context