Who can style what?

Shadow DOM · CSS styling strategies · Lit + custom properties

Five ways to let the outside style the inside.

Every web component draws a line. On one side is structure the author controls; on the other are surfaces the consumer is allowed to touch. These five strategies move that line — from total encapsulation, where only named variables may change, to no boundary at all, where ordinary CSS reaches everything. The question is never “how do I style this” but who is allowed to style what.

← Strictest · total encapsulation no boundary · loosest →
Avoid

Never reparent slotted content into the shadow root. The moment you move projected nodes behind the boundary (e.g. cloning slot children into your own template), the consumer loses the ability to style their own content with normal CSS — you have silently demoted them from author of their markup to a guest behind your wall.

01

Locked

Public CSS custom properties only · the private/public pattern

Encapsulation Total — only declared vars change

Good for: button, badge, checkbox, radio, tag, spinner

A Locked component seals its structure and exposes a short, documented list of CSS custom properties. Internally it reads private --_nys-* variables that fall back through a public --nys-* hook to a design token. Consumers can retheme it through those names, but they can never reach a node you didn’t hand them a variable for — anything not published simply cannot change.

Live demo<nys-lock-button>click them — they work

Defaults


Restyled — purely by setting --nys-* from outside

  • What the consumer stylesheet is doing here
  • The top button sets no custom properties, so every value falls through the chain to its token / hard default — deep blue, square-ish 6px corners.
  • The .danger class sets --nys-lockbtn-bg, --nys-lockbtn-radius, color and padding → red, pill-shaped, roomier. Same sealed element, only the published values differ.
  • There is no way to change the font-weight, add an icon, or restyle the inner <button> — the author published no variable for those, so they stay sealed.
css static stylesauthor

        
html render() · sealed internalsauthor

        
html consumer markup · how you use itconsumer

        
css consumer stylesheetconsumer

        
Limit

The consumer can change only the values you declared. New layout, new elements, or restyling an internal node that has no variable is impossible — which is exactly the guarantee you want for atomic controls that must stay on-brand everywhere.

02

Themeable

::part() — curated internal elements, exposed by name

Encapsulation High — only parted nodes are reachable

Good for: tabs, accordion, select, dialog, toast, stepper

The author tags internal elements with a part attribute to publish them by name. This tab strip exposes four parts: tablist, tab, the state-variant tab--selected, and badge — the small count bubble on each tab. The consumer then restyles any of them from their own stylesheet with nys-tabs::part(name), with no knowledge of the shadow DOM around them. A part is a deliberate, named seam: the author decides exactly which nodes are reachable — exposing badge means “you may theme the count bubble,” and nothing else inside the tab comes with it. Because ::part() reaches the parted node and stops, anything the author didn’t part stays sealed.

Live demo<nys-tabs>click a tab
  • What the consumer stylesheet is doing here
  • ::part(tab) uppercases every tab label and tightens the letter-spacing.
  • ::part(tab--selected) paints only the active tab red with an underline — that token is present on just the selected tab, so clicking another tab moves it.
  • ::part(badge) fills the count bubbles red. This works only because the author gave the badge its own part — that’s the whole reason the part exists.
  • ::part(tab) span tries to recolor the label by reaching the <span> inside the tab — and silently does nothing. You can see it: the labels have no red outline. ::part() can’t select a descendant of a part.
css static stylesauthor

        
html render()author

        
css consumer stylesheetconsumer

        
The limit

nys-tabs::part(tab) span matches nothing. A pseudo-element can’t have shadow descendants selected through it: ::part() stops at the parted node and refuses to descend into the shadow tree. The fix is not a fancier selector — it’s to give the descendant its own part (part="badge") so it becomes directly addressable.

03

Themeable + nested

exportparts — forward a child’s part through the parent boundary

Encapsulation High — but reaches two boundaries deep

Good for: alert-with-action, card-with-button, composite/compound components

::part() only crosses a single shadow boundary. When a component renders another component inside its own shadow root, the inner part is invisible from the page. exportparts re-publishes a child’s part through the parent’s boundary — optionally renaming it — so the page can finally reach two levels deep.

Live demo<nys-alert>two shadow roots
  • What the consumer stylesheet is doing here
  • nys-alert::part(box) repaints the alert’s tinted box amber — that part lives in the alert’s own shadow root, one boundary deep.
  • nys-alert::part(forwarded-label) reds and underlines the “Save now” text. That label lives inside the child button — two boundaries deep — and is only reachable because the alert re-published it with exportparts.
  • nys-alert::part(label) does nothing — the child’s raw label name doesn’t cross the parent’s boundary; only the forwarded name works.

The two boundaries, made explicit

nys-alert · shadow root part="box", part="text" live here → reachable as ::part(box)
nys-mini-button · shadow root part="label" lives here. Invisible to the page unless the parent forwards it. After exportparts → ::part(forwarded-label).
css child · nys-mini-buttonauthor

        
html child render()author

        
html parent · nys-alert render()author

        
css consumer stylesheetconsumer

        
Limit

Forwarding is explicit and one hop at a time. nys-alert::part(label) does nothing (the child’s raw name doesn’t cross the parent’s boundary); only the re-published forwarded-label is reachable. Three-deep nesting means the middle component must forward again.

04

Slotted

<slot> — a thin frame around the consumer’s own light DOM

Encapsulation Frame sealed · content fully open

Good for: card, panel, modal body, list item, anything wrapping rich content

A slotted frame keeps the consumer’s content in the light DOM and only projects it through named <slot>s. Because that content never crosses the boundary, the consumer styles it with completely normal CSS at any depth — including a span nested inside a p inside the slot. The author can set baseline styles with ::slotted(), but only on the top-level projected node.

Live demo<nys-card>consumer content, projected
Saratoga Springs

A weekend upstate

Mineral springs and a nested span buried deep inside a paragraph.

Updated 2 minutes ago
  • What the consumer stylesheet is doing here
  • .card-media (plain page CSS) paints the blue banner — the top-level projected node.
  • .body-content h3 swaps the heading to a serif — a child of your slotted wrapper.
  • .body-content p .pill styles the green pill — a descendant of a descendant; normal CSS ignores depth because the content never left your light DOM.
  • The author’s ::slotted([slot="body"] *) never matches that pill — ::slotted() only sees the top-level projected node, not what’s nested inside it.
css static styles · author baselineauthor

        
html render() · the frameauthor

        
html consumer markup · light DOMconsumer

        
css consumer stylesheet · normal CSSconsumer

        
(a) Normal CSS → ANY depth ✓
The consumer owns the markup. .body-content p .pill reaches a descendant of a descendant — no boundary is in the way.
(b) ::slotted() → TOP LEVEL only ✗
The author’s ::slotted([slot="body"] *) never matches — once you ask about a descendant of a projected node, ::slotted() is out of reach.
05

Light DOM

createRenderRoot() returns this — no shadow root at all

Encapsulation None — everything is reachable

Good for: data tables, generated lists, grids — structure parts can’t enumerate

Overriding createRenderRoot() to return this renders the component into the light DOM — no shadow root is created. That means static styles no longer applies, so every style comes from the page, and ordinary selectors reach the elements the component generates. You trade away encapsulation for total reach.

Live demo<nys-data-table>rows built from a data array
  • What the consumer stylesheet is doing here
  • .nys-dt thead th gives the header its dark bar — an ordinary class selector reaching the generated <th> cells, no ::part() needed.
  • .nys-dt tbody tr:nth-child(even) stripes the rows the component built from its data array — the page selects nodes the component generated.
  • .nys-dt tbody tr:hover highlights a row on hover — try it.
  • Nothing here is scoped: a stray global table td { … } elsewhere on the page would hit these cells too. That total openness is the whole point — and the whole risk.
js the componentauthor

        
html render() · generated structureauthor

        
css consumer stylesheet · normal selectorsconsumer

        
Limit / when

There’s no boundary, so nothing is scoped or protected — global CSS can hit these elements by accident, and you lose Shadow DOM’s style isolation. That’s an acceptable trade precisely when the component emits unpredictable structure (table cells, list items, grid tracks) that a fixed set of parts could never enumerate.