# Shadow DOM vs iframes for third-party embeds

I choose Shadow DOM when an embed must act on the host page and the host trusts it, and an iframe when either side must not trust the other. My storefront bookings widget touches the host document in five places; three of them would need a second script on the host from inside a cross-origin iframe. Shadow DOM isolates styles, not scripts, so card entry stays out of the widget.

Published: 2026-10-09
Canonical: https://umar.codes/shadow-dom-vs-iframes

## Revisions

- 2026-10-09 — Created from the bookings widget's source, build output and commit history, read that day (bundle sizes measured on the committed build).
- 2026-10-09 — Published on its Oct 9 slot; auto-drafted by the publishing run because no draft existed for the row.

---

I choose Shadow DOM when an embed must act on the host page and the host trusts
it, and an iframe when either side must not trust the other. My storefront
bookings widget touches the host document in five places. A cross-origin iframe
would need a host script for three of them.

The widget is the one from [a widget in someone else's page](/widget-in-someone-elses-page):
Preact in a Shadow DOM, shipped as a Shopify theme app extension. That log covers
the build. This one covers the boundary choice, from the same code.

## Shadow DOM isolates styles; an iframe isolates everything

A shadow root is a style and selector boundary. The host theme's CSS does not
reach in, the widget's CSS does not leak out, and a host `querySelector` does
not find the widget's elements. Everything else is shared: one JavaScript realm,
one set of globals, one origin, one event loop, one viewport.

An iframe is a separate document. It has its own realm, its own viewport, and
when it is cross-origin, its own origin. The host cannot read it and it cannot
read the host. Every interaction between the two goes through `postMessage`.

So the question is not "which isolates better". The iframe always isolates
more. The question is which of those shared things the embed needs.

## Five places a storefront widget touches the host page

I listed every use of `document`, `window` and `location` in the widget's
source, excluding tests and the code that creates its own nodes. Five contacts
with the host page remain:

| Contact | Why it exists | From a cross-origin iframe |
|---|---|---|
| Three `<link>` elements in `document.head` | Fonts: `@font-face` inside a shadow root is unreliable | Not needed: the frame loads its own fonts |
| A capture-phase click listener on `document` | Any CTA on the site opens the booking popup (`data-book`, or a `#book-cafe` link) | Needs a host script |
| A `hashchange` listener on `window` | The popup opens when a page loads with a booking hash | Needs a host script |
| `document.body.style.overflow = "hidden"` | Stops the page scrolling behind the modal | Needs a host script |
| `window.location.assign(invoiceUrl)` | Sends the guest to the deposit invoice | Needs top-navigation permission on the frame |

The second row is the one that decided it. The feature that let any button on
the site open the booking flow was one 94-line module, `triggers.ts`, and five
unit tests. With an iframe, the host page would need its own script to catch
those clicks and post a message into the frame. At that point the embed ships
JavaScript into the host anyway. The iframe adds a message protocol and removes
nothing.

## A modal inside an iframe stops at the iframe's edge

The booking flow runs in a native `<dialog>` opened with `showModal()`. That
puts it in the browser's top layer: above every element on the page, with a
backdrop across the whole viewport, and with focus held inside it. The theme's
`z-index` values do not matter.

Inside an iframe, the top layer belongs to the frame's document. The dialog and
its backdrop stop at the frame's rectangle. A full-screen booking modal from an
iframe means a host script that resizes the frame to cover the viewport, locks
the host scroll, and returns focus on close. That is the same host script again,
plus height messages, because the frame cannot size itself to content that
changes on each of the two steps.

In the shadow root, the scroll lock is three lines. The dialog saves the body's
`overflow`, sets it to `hidden`, and restores the saved value on close.

## What Shadow DOM costs: duplicated CSS and an open door

The widget's bundle is 55,791 bytes minified and 18,179 bytes gzipped. That
includes 13,132 bytes of compiled Tailwind CSS, imported as a string. The widget
mounts two shadow roots, the inline block and the site-wide popup, and each one
gets its own `<style>` element with the full string. On a page that has both
blocks, the browser parses the same 13 KB twice. `adoptedStyleSheets` would let
both roots share one parsed sheet. I have not made that change, because the
duplicate costs parse time, not network bytes.

The larger cost is trust. The roots are `mode: "open"`, and the host's scripts
run in the same realm as the widget. Any theme script, analytics tag or other
app embed on the storefront can reach the form and read the four personal fields
it collects: first name, last name, email and phone. A closed root does not fix
this. A script that runs first can wrap `attachShadow`, and composed events such
as `input` still reach listeners on the host.

That was acceptable here for two reasons. The host page is the client's own
storefront, so the scripts on it are scripts they already chose to trust with
their customers. And the widget never takes a card. It posts the booking to
`/apps/bookings`, a Shopify app proxy on the storefront's own origin, and then
navigates the whole page to a deposit invoice that Shopify hosts. The payment
step happens in a page the widget does not control, which is the strongest
isolation available.

The same-origin proxy is a small extra benefit. The widget's requests carry no
CORS preflight and need no third-party cookie. A frame served from the app's own
domain would be cross-origin on every storefront it appears on.

## When I would choose the iframe instead

I would use an iframe for any of these, whatever it costs in host scripts:

1. **The embed takes card details, passwords or tokens.** The host must not be
   able to read the fields. Shadow DOM cannot promise that.
2. **The embed runs on hosts I do not know.** A widget sold to many sites meets
   theme scripts nobody has reviewed. Its own origin is the only boundary that
   holds on all of them.
3. **The embed needs its own session.** If it must stay signed in to my domain,
   the frame keeps the cookies on my origin.

None of the three applied to the bookings widget. It runs on one known
storefront, collects four contact fields, and hands payment to a page that the
platform hosts. For that shape, Shadow DOM gave me the host page's full viewport
and its click events, and an iframe would have given me a protocol to rebuild
them.
