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.
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.
Locked
Public CSS custom properties only · the private/public pattern
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.
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
.dangerclass 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.
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.
Themeable
::part() — curated internal elements, exposed by name
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.
- 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) spantries 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.
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.
Themeable + nested
exportparts — forward a child’s part through the parent boundary
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.
- 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 withexportparts.nys-alert::part(label)does nothing — the child’s rawlabelname doesn’t cross the parent’s boundary; only the forwarded name works.
The two boundaries, made explicit
::part(box)
::part(forwarded-label).
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.
Slotted
<slot> — a thin frame around the consumer’s own light DOM
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.
A weekend upstate
Mineral springs and a nested span buried deep inside a paragraph.
- What the consumer stylesheet is doing here
.card-media(plain page CSS) paints the blue banner — the top-level projected node..body-content h3swaps the heading to a serif — a child of your slotted wrapper..body-content p .pillstyles 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.
The consumer owns the markup.
.body-content p .pill reaches a
descendant of a descendant — no boundary is in the way.
The author’s
::slotted([slot="body"] *) never matches — once you
ask about a descendant of a projected node, ::slotted() is out of reach.
Light DOM
createRenderRoot() returns this — no shadow root at all
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.
- What the consumer stylesheet is doing here
.nys-dt thead thgives 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:hoverhighlights 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.
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.