The brief: add a mobile services drawer to the Calegix portal. Desktop already had a persistent left rail with hover-peek. Mobile was hiding it at @media (max-width:640px) { .rail { width: 0 } } and offering no way to open it. Phone users were locked out of the whole nav.
The trick: the rail is injected cross-origin. The same script tag runs on calegix.com, mautic.calegix.com, analytics.calegix.com - four unrelated apps I don't want to style-pollute. I need mobile UX without leaking a single CSS rule into the host page.
Shadow DOM is the sleeper feature of the 2020s web platform for exactly this. Total addition: about sixty lines including the SVG icon. Let's walk through it.
The existing embed
A single <script src> is loaded by every authenticated page. It mounts a fixed-position host element, attaches a closed shadow root, and drops an iframe inside pointing at our portal sidebar.
function mount() {
var host = document.createElement("div");
host.id = "calegix-portal-host";
host.style.cssText = "all:initial;position:fixed;top:0;left:0;" +
"height:100vh;z-index:2147483600;pointer-events:auto;";
document.documentElement.appendChild(host);
var root = host.attachShadow({ mode: "closed" });
// ... inject style + rail + iframe ...
} Three things matter here:
1. all:initial on the host. Even outside the shadow root, the host element inherits zero from the page it's in.
2. mode:'closed' on the shadow. The host page cannot reach into my DOM via element.shadowRoot. No accidental CSS-in-JS libraries touching my internals.
3. z-index: 2147483600 - the max 32-bit signed int minus a small buffer. Above every practical z-index a host page might use.
Mobile drawer - the CSS
The drawer is the existing rail with a translate-x transform added, gated behind a media query, plus a FAB and backdrop that only appear on narrow viewports:
/* inside the shadow <style> */
@media (max-width: 640px) {
.rail {
width: 260px;
border-right: 0;
transform: translateX(-100%);
box-shadow: none;
}
.rail.pinned {
transform: translateX(0);
border-right: 1px solid rgba(241,241,241,0.08);
box-shadow: 2px 0 40px rgba(0,0,0,0.85);
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
opacity: 0;
pointer-events: none;
transition: opacity 280ms ease;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
z-index: -1;
}
.backdrop.show {
opacity: 1;
pointer-events: auto;
z-index: 0;
}
.fab {
position: fixed;
right: calc(env(safe-area-inset-right, 0px) + 14px);
bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
width: 44px;
height: 44px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
background: radial-gradient(circle at 30% 30%,
rgba(0,212,165,0.18), rgba(10,10,10,0.92));
border: 1px solid rgba(0,212,165,0.55);
color: #00d4a5;
box-shadow: 0 0 0 1px rgba(0,0,0,0.6),
0 6px 18px rgba(0,0,0,0.55),
0 0 18px rgba(0,212,165,0.25);
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.fab:active { transform: scale(0.92); }
}
@media (min-width: 641px) {
.fab, .backdrop { display: none !important; }
} Details worth calling out:
- env(safe-area-inset-right) and env(safe-area-inset-bottom) keep the FAB clear of rounded screen corners + iOS home-indicator gesture area.
- -webkit-tap-highlight-color: transparent suppresses the iOS blue-grey flash that looks cheap against an emerald-accent button.
- The backdrop uses z-index:-1 when hidden and z-index:0 when shown. That's the one trick - a negative z-index on the hidden backdrop means the host page's clicks pass straight through without hitting our shadow root at all. Pointer-events:none would work too but z-index is more robust across browsers.
Mobile drawer - the JS
Three event listeners do all the work:
var rail = root.querySelector(".rail");
var backdrop = root.querySelector(".backdrop");
var fab = root.querySelector(".fab");
var mql = window.matchMedia("(max-width:640px)");
function setMobileOpen(open) {
rail.classList.toggle("pinned", open);
backdrop.classList.toggle("show", open);
}
fab.addEventListener("click", function () {
if (!mql.matches) return;
setMobileOpen(!rail.classList.contains("pinned"));
});
backdrop.addEventListener("click", function () { setMobileOpen(false); });
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && mql.matches &&
rail.classList.contains("pinned")) {
setMobileOpen(false);
}
}); Escape-to-close is a small touch that makes Bluetooth-keyboard users on tablets happy.
Ephemeral vs persistent pin
On desktop the rail remembers its pinned state across page loads via localStorage. On mobile, it shouldn't: phone users come to open one service, then close the drawer. A drawer that re-opens on every page visit would be infuriating.
The pattern:
function mqlCheck() {
if (mql.matches) {
// Mobile: reset to closed every viewport change
rail.classList.remove("pinned");
backdrop.classList.remove("show");
} else {
// Desktop: respect the persisted localStorage choice
if (isPinned()) rail.classList.add("pinned");
else rail.classList.remove("pinned");
backdrop.classList.remove("show");
}
}
mql.addEventListener("change", mqlCheck);
mqlCheck(); Same rail element, two different interaction models, both driven by viewport width. Twelve lines.
Why Shadow DOM is the right tool
I could have built this with:
- A same-origin iframe (no CSS leak, but clunky and adds a whole document lifecycle).
- A strict CSS namespace prefix (leaks if host declares !important or has a naked * selector).
- A CSS-in-JS library like emotion (adds 20KB of dependency for zero benefit).
Shadow DOM costs zero additional bytes beyond the host element itself. It gives you a truly sealed style context. And because the mode is closed, even a hostile script on the host page can't reach into the shadow and mess with my drawer.
Every time I build a cross-origin injection like this I reach for Shadow DOM first. The browser has done the work. The downside - limited interop with some a11y tooling - has narrowed to almost nothing in 2026.
Deployment notes
The script is cache-max-age=300 (5 min) so updates roll out to clients within a refresh cycle. That matched my tolerance for 'oops, forgot a semicolon' moments. Longer caches need a version query string.
SVG for the FAB icon is inline in the JS. Nine circles in a 3×3 grid. About 400 bytes. Beats loading a separate SVG file in every injection.
One accessibility gotcha: the FAB has aria-label='Open services'. It should probably also toggle aria-expanded when the drawer opens. I haven't added that yet - next iteration.
No comments yet