Building a Voice-First AI Chrome Extension: How Shadow DOM Saved Our UI

You can now have actual phone conversations with AI to create tweets. Our Chrome extension brings the same conversational AI experience you’d get from calling our voice assistant directly to any webpage. Speak naturally, get instant responses, create content with your voice.

But first, we had to solve a critical problem: every major website was destroying our UI.

The Voice-First Content Creation Challenge

X11.Social is a voice-first platform. Users call our AI assistant like they’d call a friend—having natural conversations to create, schedule, and publish content. Our Chrome extension brings this same experience directly into your browser with a Quake-style console and floating voice widget.

It worked beautifully… until users tried it on real websites.

The Breaking Points

  • Twitter/X: Voice widget became invisible, console fonts unreadable
  • YouTube: Voice recording button disappeared behind video player
  • Tailwind sites: Complete chaos—button styles, colors, spacing all broken
  • Radix UI sites: Dropdowns and modals rendered incorrectly

Every site broke our voice interface differently. Users couldn’t record audio, couldn’t see AI responses, couldn’t create content. The same AI that handles phone conversations flawlessly was unusable in the browser.

What We Tried (And Why It Failed)

Attempt 1: CSS Prefixing

Added x11- to every class name.

Result: Our classes stopped conflicting, but sites still overrode our styles with !important rules. Failed.

Attempt 2: Inline Styles

Moved everything to inline styles with JavaScript.

Result: Unmaintainable mess. No hover states. No media queries. Failed harder.

Attempt 3: CSS Modules with Scoping

Used CSS modules to generate unique class names.

Result: Better, but global resets still affected us. Font sizes still broken on YouTube. Failed.

Attempt 4: iframe Isolation

Put everything in an iframe.

Result: Lost access to page context. Couldn’t interact with the page. Complete non-starter. Failed.

Attempt 5: More !important

Added !important to everything.

Result: Arms race with host CSS. Made problems worse. Shamefully failed.

The Solution: Shadow DOM + Compiled CSS

Shadow DOM creates a boundary that CSS cannot cross. Here’s exactly how we made it work:

Step 1: Create Shadow DOM Container

// src/lib/shadow-dom.ts
export async function createShadowDOMContainer(id: string) {
  const container = document.createElement('div')
  container.id = id
  
  // Create shadow root - the magic boundary
  const shadowRoot = container.attachShadow({ 
    mode: 'open' // Use 'open' so our React components can access it
  })
  
  // Create mount point for React inside shadow
  const mountPoint = document.createElement('div')
  shadowRoot.appendChild(mountPoint)
  
  document.body.appendChild(container)
  
  return { container, shadowRoot, mountPoint }
}

Step 2: Compile Complete Tailwind CSS

Shadow DOM blocks external styles, but that means NO styles get in. We needed to compile ALL our CSS, including Tailwind.

// scripts/compile-css.mjs
import postcss from 'postcss'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'

// Compile EVERYTHING - all Tailwind utilities, not just used ones
const processor = postcss([
  tailwindcss({
    content: [
      // Force generation of ALL utilities
      { raw: '<div class="every possible tailwind class here">', extension: 'html' }
    ]
  }),
  autoprefixer()
])

// Results in 90KB CSS file with everything

Step 3: Transform CSS for Shadow DOM

Global selectors don’t work in Shadow DOM. We built a PostCSS plugin to transform them:

// scripts/shadow-dom-transformer.mjs
const shadowDOMTransformer = () => {
  return {
    postcssPlugin: 'shadow-dom-transformer',
    Once(root) {
      root.walkRules((rule) => {
        // Transform html selector to :host
        if (rule.selector === 'html') {
          rule.selector = ':host'
        }
        
        // Transform body to container div
        if (rule.selector === 'body') {
          rule.selector = ':host > div'
        }
        
        // Transform universal selector
        if (rule.selector === '*') {
          rule.selector = ':host *'
        }
      })
    }
  }
}

Step 4: Inject Styles into Shadow DOM

Constructable stylesheets are the modern way, but have spotty support. We built a fallback:

// Load and inject compiled CSS
async function loadStyles(shadowRoot: ShadowRoot) {
  try {
    // Try modern constructable stylesheets
    const response = await fetch(chrome.runtime.getURL('styles/shadow-dom.css'))
    const css = await response.text()
    
    const sheet = new CSSStyleSheet()
    await sheet.replace(css)
    shadowRoot.adoptedStyleSheets = [sheet]
  } catch {
    // Fallback: inject as style element
    const response = await fetch(chrome.runtime.getURL('styles/shadow-dom.css'))
    const css = await response.text()
    
    const styleElement = document.createElement('style')
    styleElement.textContent = css
    shadowRoot.appendChild(styleElement)
  }
}

Step 5: Mount React Inside Shadow DOM

// src/content-enhanced.tsx
const { shadowRoot, mountPoint } = await createShadowDOMContainer('x11-root')

// React portal to render inside Shadow DOM
const root = createRoot(mountPoint)
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

The Gotchas That Almost Killed Us

CSS Variables Don’t Inherit

Shadow DOM blocks CSS variable inheritance. Solution: Define all variables on :host.

Fonts Need Special Handling

Google Fonts links don’t work due to CSP. Solution: Include font in CSS with @import.

Global Selectors Need Rewriting

html, body, * selectors don’t work. Solution: Transform to :host and :host *.

Build Process Matters

Vite doesn’t compile imports in CSS by default. Solution: Use postcss-import plugin.

Radix UI Portals Need Special Care

Radix UI components render portals outside the Shadow DOM by default. They need custom container discovery and proper z-index hierarchy.

The Portal Problem

// Radix portals look for document.body by default
// But our UI is inside Shadow DOM!
<DropdownMenu.Portal> // This renders to document.body ❌

The Portal Solution

// Custom hook to find Shadow DOM container
function usePortalContainer() {
  const possibleIds = ['x11-console', 'x11-voice', 'x11-root']
  
  for (const id of possibleIds) {
    const container = document.getElementById(id)
    const shadowRoot = container?.shadowRoot
    if (shadowRoot) {
      // Create or find portal container inside Shadow DOM
      let portalContainer = shadowRoot.querySelector('.x11-portal-container')
      if (!portalContainer) {
        portalContainer = document.createElement('div')
        portalContainer.className = 'x11-portal-container'
        shadowRoot.appendChild(portalContainer)
      }
      return portalContainer as HTMLElement
    }
  }
  return document.body // Fallback
}

// Use in components
<DropdownMenu.Portal container={portalContainer}>

Z-Index Hierarchy Is Critical

With Shadow DOM + portals, z-index needs careful planning:

/* Main console */
.x11-console {
  z-index: 10000; /* Base layer */
}

/* Portal container for dropdowns */
.x11-portal-container {
  z-index: 50000; /* Above console */
}

/* Dropdown content */
[data-radix-popper-content-wrapper] {
  z-index: 60000 !important; /* Above portal container */
}

CSS Variables Must Be Explicit

Shadow DOM CSS variables don’t inherit automatically. Every variable must be defined:

/* Wrong - uses generic border class */
.border { border: 1px solid; } /* Falls back to currentColor */

/* Right - uses explicit variable */
.border-border { border: 1px solid hsl(var(--border)); }

The Results: Voice AI That Works Everywhere

  • Voice widget fully functional - Users can now record audio on any site
  • Console renders perfectly - Clean, readable interface for AI conversations
  • Dropdowns work correctly - Model selectors, settings, all interactive elements
  • Consistent experience - Same UI whether you’re on Twitter, YouTube, or GitHub
  • Voice-first features intact - Call-to-tweet, voice scheduling, AI conversations all working

Now users can have the same natural voice conversations with AI in their browser as they would on a phone call. Create content by speaking. Schedule posts with voice commands. Have back-and-forth conversations with AI—all without leaving the webpage you’re on.

Try This Now

Building a Chrome extension with complex UI? Here’s your checklist:

  1. Start with Shadow DOM - Don’t wait until you have conflicts
  2. Compile all CSS - Shadow DOM needs everything inside
  3. Transform selectors - Global selectors won’t work
  4. Build fallbacks - Not all browsers support constructable stylesheets
  5. Test everywhere - Twitter, YouTube, and Tailwind sites are good stress tests

The Code That Actually Works

# Install dependencies
yarn add -D postcss postcss-import tailwindcss autoprefixer

# Compile CSS with transformations
node scripts/compile-css.mjs

# Build extension
yarn build

What This Means for Voice-First Extensions

Shadow DOM made our voice AI Chrome extension possible. Without it, we couldn’t deliver the same conversational AI experience that users get from calling our voice assistant directly.

The complexity was worth it. Users can now:

  • Call their AI assistant from any webpage
  • Create content by speaking naturally, like a phone conversation
  • Get instant AI responses with proper UI rendering
  • Schedule and publish without typing a single word

Shadow DOM isn’t optional for complex Chrome extensions—especially those with voice interfaces, real-time interactions, and rich UI components. It’s the foundation that makes browser-based conversational AI actually work.

Experience Voice-First Content Creation

Want to create content by just talking? Our Chrome extension brings the same conversational AI you’d get from calling our voice assistant directly to your browser. Have natural conversations, create tweets by speaking, schedule content with voice commands—all while browsing any website.

The same AI, the same voice experience, now in your browser.

Get the X11.Social Chrome Extension →

← Back to Blog