# Shadow DOM, agent chat, and not fighting your CSS
Why Linda renders the chat UI into a Shadow DOM, how it stays style-isolated against arbitrary host CSS, and the surprising number of edge cases that this design solves for free.
**Published:** 2026-05-04  
**Tags:** shadow-dom, css, ui, architecture  
**Cluster:** technical  
---
Embedding a chat UI in an arbitrary web page is a small problem that grows fast. Day one: it works on your demo page. Day two: someone embeds it on a site with `* { box-sizing: border-box }` somewhere weird and your padding goes haywire. Day three: a customer's host CSS has `body { font-family: Comic Sans }` and now your chat is Comic Sans.

You can fight this with `!important` everywhere, which is awful. You can fight it with CSS specificity hacks, which is fragile. Or you can stop fighting and use the Shadow DOM.

Linda's chat UI lives in a Shadow DOM. Here's why, what it gets us for free, and the surprising number of issues this design solves before they show up.

## The Shadow DOM primer

Shadow DOM is a browser-native style isolation primitive. You attach a "shadow root" to a host element, render your subtree inside it, and the page's CSS can't reach in (mostly). Your CSS, conversely, can't reach out.

```html
<div id="linda-host"></div>
<script>
  const host = document.getElementById("linda-host");
  const shadow = host.attachShadow({ mode: "open" });
  shadow.innerHTML = `
    <style>
      .chat { background: white; padding: 16px; }
    </style>
    <div class="chat">Hello.</div>
  `;
</script>
```

Now your `.chat` class is scoped to the shadow root. No matter what the host page's CSS does, the `.chat` inside your shadow is isolated.

## What this solves

Real things we've seen happen when you don't use Shadow DOM:

- **Reset stylesheets clobbering your padding.** Host pages with `* { margin: 0; padding: 0 }` ruin spacing.
- **Font inheritance.** Hosts with `body { font-family: Comic Sans }` propagate to your chat.
- **Specificity wars.** Customer ships `.chat-bubble { background: red !important }` to "match brand" and breaks your dark-mode logic.
- **`box-sizing` mismatches.** Host uses `content-box` somewhere; your buttons end up the wrong size.
- **CSS variable collisions.** Host defines `--primary` for their brand; you define `--primary` for your accent. Whose wins depends on tree position. Chaos.

Each of these is fixable with `!important` and specificity tricks. They all go away with Shadow DOM.

## What Shadow DOM doesn't solve

It's not a perfect wall. Things that still cross:

- **Inherited CSS properties.** `font`, `color`, `direction`, etc. — if your shadow root doesn't set them explicitly, they inherit from the host. (Linda sets all the relevant ones on the shadow root.)
- **CSS custom properties.** Variables defined on `:root` *do* cross into the Shadow DOM. This is actually useful — you can let host pages theme your chat by defining `--linda-accent`.
- **Form elements.** `<input>` and `<button>` have user-agent styles that bleed in. Linda explicitly resets these inside the shadow.

The leakage is small and surgical. With a careful reset (~30 lines of CSS at the top of the shadow), it's bulletproof.

## The Linda implementation

Linda's chat is mounted into a shadow root on a `<linda-chat>` custom element. Inside the shadow:

```ts
class LindaChat extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });

    // Inject a reset + the chat CSS.
    const styles = document.createElement("style");
    styles.textContent = LINDA_RESET + LINDA_CHAT_CSS;
    shadow.appendChild(styles);

    // Mount the chat subtree.
    const root = document.createElement("div");
    root.className = "linda-root";
    shadow.appendChild(root);

    // ...attach the actual chat component.
  }
}
customElements.define("linda-chat", LindaChat);
```

The reset:

```css
.linda-root, .linda-root * {
  box-sizing: border-box;
  font-family: var(--linda-font, -apple-system, BlinkMacSystemFont, sans-serif);
  color: var(--linda-ink, #0b1020);
  font-size: 16px;
  line-height: 1.5;
  margin: 0;
  padding: 0;
}
```

That's the magic. Everything inside `.linda-root` starts from a known state. Host CSS can't intrude.

## Theming through custom properties

Custom properties cross into the shadow. So we expose a documented set:

```css
:root {
  --linda-accent: #4f46e5;      /* your brand */
  --linda-surface: #ffffff;     /* chat bg */
  --linda-ink: #0b1020;         /* text */
  --linda-radius: 12px;         /* corners */
  --linda-font: system-ui;      /* font */
}
```

Host pages set whichever they want. The chat picks them up. Themed chat in 3 lines of host CSS.

## Accessibility surprises

The big worry about Shadow DOM is "does it break screen readers?" The answer: not if you do it right.

- **ARIA roles cross the shadow.** A `role="dialog"` inside the shadow announces correctly.
- **Focus management works.** `aria-activedescendant`, focus traps, all functional.
- **Live regions announce.** `aria-live="polite"` inside the shadow works for streaming chat messages.

What you have to watch for:

- **`<label for=...>` doesn't cross the shadow.** Use `aria-labelledby` or wrap labels.
- **The host page's "skip to content" link can't target your shadow.** Provide your own skip link inside the shadow.

Linda's chat ships with a skip link, focus management, ARIA live regions for streaming responses, and keyboard navigation (Tab through buttons, Enter to send, Escape to close).

## When you'd skip the Shadow DOM

A few cases where Shadow DOM is wrong:

- **You want the chat to inherit ambient styles.** If your chat is supposed to look exactly like the host site (matching fonts, colors, spacing), inheriting is what you want.
- **You're shipping into a known environment.** If the chat only lives inside your own site, where you control all the CSS, Shadow DOM is overhead.
- **Heavy DOM interaction with the host page.** Shadow DOM makes "find the button in the host" slightly more verbose.

For Linda's case — chat lives on arbitrary customer pages, must not break — Shadow DOM is the clear win.

## The principle

> Defensive defaults beat clever escape hatches.

Most "embed this on your page" problems come from designs that assume best-case host CSS. The script tag works on the demo and breaks in production. Shadow DOM flips the default: you start from a known state, and host customization is opt-in via documented custom properties.

This is the same shape as Linda's broader bet — agents shouldn't assume their context is friendly. The VFS lets the model navigate explicitly. The Shadow DOM lets the chat render predictably. Both come from the same instinct: be defensive about the substrate, then build clean things on top.

## What you can take from this

If you're embedding a UI on third-party sites for any reason — a widget, a chat, a notification toast, a CMS overlay — use a Shadow DOM. The 30 lines of reset CSS are worth more than every CSS-specificity hack you'd otherwise write. The cost is real (slightly weirder dev experience, some accessibility nuance) and the wins compound over time.

For Linda's specific implementation, see [/features/vfs](/features/vfs) (the related architectural choice on the agent side) and the chat code in [packages/core](https://github.com/neul-labs/linda/tree/main/packages/core). It's the smallest file in the whole runtime, and it's the one that prevents the most production incidents.