DEV Community

Michael Yousrie
Michael Yousrie

Posted on

Things Shadow DOM does not isolate

A while back I wrote about using the Shadow DOM to isolate an embeddable widget, and the summary was: styles do not leak in, styles do not leak out, your install shrinks to one script tag.

That is all still true. It is also the optimistic half. "Isolated" turned out to be a stronger word in my head than it is in the browser, and every gap costs you a bug on somebody else's page, where you cannot open devtools.

This is the other half. Everything below still crosses the boundary, roughly in the order it caused me trouble building the changelog widget for Patchlog.

Inheritance walks straight in

The boundary blocks selectors. It does not block inheritance.

font-family, color, line-height, letter-spacing, text-transform, visibility, cursor and direction all arrive from the host's ancestors. Drop your widget on a page with text-transform: uppercase on a wrapper div and the whole panel is shouting.

:host {
  all: initial;
  display: block; /* all: initial resets display to inline */
}
Enter fullscreen mode Exit fullscreen mode

That second line matters more than it looks. all: initial sets display back to its initial value, which is inline, so your layout quietly collapses if you forget it.

rem is document scoped

A shadow root does not get its own root element for font sizing, so rem resolves against the host page's html element rather than against anything of yours.

A page still using the old html { font-size: 62.5% } trick renders your widget at 62.5% of every size you designed. I replaced every rem with px and em. Not elegant, but predictable, and predictability is worth more when you do not control the page.

event.target is rewritten, which cuts both ways

My earlier post framed retargeting as a feature, because it stops you leaking internal structure. It is also a trap in the other direction.

By the time a listener on document sees a composed event, target has been rewritten to your host element. So the host page's own click tracking records one identical element for every click anywhere inside your widget.

document.addEventListener('click', (e) => {
  const real = e.composedPath()[0]; // not e.target
});
Enter fullscreen mode Exit fullscreen mode

document.activeElement has the same shape of problem and returns the host. Use host.shadowRoot.activeElement for what is genuinely focused.

ID references do not cross, so ARIA breaks silently

aria-labelledby, aria-describedby and for are all ID references, and IDs are scoped per root. An ID reference pointing across a shadow boundary does not resolve.

It is not deprecated, it is not discouraged, it simply does nothing, and nothing warns you. Keep the label and the control in the same root, or use aria-label with a literal string. There is spec work on cross root ARIA. I would not build on it yet.

Nothing outside can select in, including things you wanted

document.querySelector cannot reach your nodes. That is the point, right up until it is not: the host's analytics selectors, their end to end tests and their browser extensions cannot reach them either.

Worth knowing before someone files a bug: Playwright pierces open shadow roots by default, Cypress needs includeShadowDom, and anybody poking at your widget with jQuery in the console will conclude it does not exist.

Layout is not isolated at all

This is the one that cost me an evening.

Shadow DOM scopes styles. It says nothing about the containing block. If any ancestor has overflow: hidden, your popover is clipped. If any ancestor has a transform or a filter, that ancestor creates a containing block, and your position: fixed element is suddenly positioned relative to it instead of the viewport.

Both are properties of where you mounted, not of your CSS. I now append the host element as a direct child of document.body and position from there, rather than rendering wherever the script tag happens to sit.

Use adoptedStyleSheets, not a stylesheet link

Put your CSS in the shadow root as an inline <style>, or use adoptedStyleSheets. A <link rel="stylesheet"> inside a shadow root gives a visible unstyled flash on every single load.

Closed mode buys you almost nothing

Anything can patch Element.prototype.attachShadow before your script runs and keep a reference to every root you create. Closed mode mostly makes your own debugging worse. I said use open last time and I still think so, just for a less flattering reason.

The tradeoff nobody mentions up front

You also give up letting the host restyle you. That is exactly what you asked for, and it means "can you make the heading smaller" stops being a two line CSS override on their end and becomes a feature request on yours.

Custom properties inherit through the boundary and ::part() exposes named hooks, so decide up front how much surface you are willing to expose, then hold that line. I went with a small fixed set: light, dark and auto, plus one accent color.

If the main selling point of your widget is that it blends into the host's design, Shadow DOM may be the wrong tool, and a carefully namespaced class prefix or an iframe will serve you better. I picked "never break a stranger's site" over "fully themeable". For something people install on trust, I still think that is the right way round, but it is a choice with a cost rather than a free win.

If you have a cleaner answer than "px and em everywhere" for the rem problem, I would genuinely like to hear it.

Top comments (0)