A smartphone glowing in darkness - mobile UX context

A 1KB Mobile Drawer for Any Web App, via Shadow DOM

Injecting mobile UX into a page you don't control, without leaking a single CSS rule into the host.

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.

Related posts

No comments yet