Embeddable Widgets with Vite, React, Tailwind 4 & Web Components

Building modern embeddable widgets with Web Components is a great way to ship standalone experiences, but we must be mindful of platform limitations when choosing our approach.

We recently built out a few embeddable widgets and chose to use React + Tailwind 4 delivered as a Web Component (built with Vite). We had confidence that this combination would work well for our needs given our familiarity with each, but we knew there would be some things to figure out, too.

So how is this supposed to work? #

Let’s start with the pieces involved:

  • React - our tool of choice for complex app-like UIs that require non-basic state management and user flows.
  • Web Components - a modern approach to web encapsulation/integration, and a welcome alternative to the dated iframe approaches.
  • Tailwind 4 - does what it says on the tin, we’ve been long-time Tailwind enjoyers.
  • Vite - for development and bundling/packaging.

The Initial Setup. #

We started with a Vite config like the following which configures a main.tsx entrypoint to be compiled as a single file named embed.js, processes Tailwind CSS, and honors TypeScript tsconfig.json paths aliases.

// vite.config.ts
import { resolve } from 'node:path';

import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');

  return {
    base: './',
    define: {
      'process.env.NODE_ENV': JSON.stringify(mode),
    },
    plugins: [
      tsconfigPaths(),
      tailwindcss(),
      react(),
    ],
    build: {
      target: 'es2020',
      lib: {
        entry: resolve(__dirname, 'src/main.tsx'),
        fileName: 'embed',
        formats: ['es'],
      },
      sourcemap: true,
    },
  };
});

main.tsx just defines and registers a Web Component which knows how to render our React app (simplified example). Importantly, we inline the CSS and attach it to the Shadow Root instead of letting Vite compile it to a separate .css file (default behavior). We do this primarily because we want the simplicity of the embedding application only needing to load 1 asset (the embed.js).

// main.tsx
import { createRoot, type Root } from 'react-dom/client';
import { EmbedWidgetComponent } from './embed-component';
import styles from './main.css?inline'

const EMBED_WIDGET_TAG_NAME = 'embed-widget';

class EmbedWidget extends HTMLElement {
  root: Root;
  rootContainer: ShadowRoot;

  constructor() {
    super();
    
    // create the Shadow Root
    this.rootContainer = this.attachShadow({ mode: 'open' });

    // attach the stylesheet inside the Shadow Root
    const style = document.createElement('style');
    style.textContent = styles;
    style.setAttribute('type', 'text/css');
    this.rootContainer.appendChild(style);

    // create the React Root
    this.root = createRoot(this.rootContainer);
  }

  // render the React app when the Web Component connects
  connectedCallback() {
    this.root.render(<EmbedWidgetComponent />);
  }
};

function registerComponent() {
  if (!customElements.get(EMBED_WIDGET_TAG_NAME)) {
    customElements.define(EMBED_WIDGET_TAG_NAME, EmbedWidget);
  }
}

registerComponent();

NOTE: A more complete component would include observedAttributes and attributeChangedCallback and pass props through to React.

Our first problem: “Where are my borders?” #

We were surprised to see that not all of the compiled Tailwind styles were working. This wasn’t entirely unprecedented given that we are managing that stylesheet and its attachment manually, but it is strange to see some but not all styles applied.

Tailwind 4 generates CSS that relies heavily on CSS Custom Properties but as of June 2026 the Shadow DOM does not support them.

The Shadow DOM provides partial encapsulation and doesn’t [currently] support CSS Custom Properties defined on the Shadow Root. The W3C specification section 2.8 suggests that @property should be applied to the complete, flattened DOM tree (essentially ignoring shadow boundaries) but current browser implementations do not fully adhere to the spec in this regard (yet?). A long-standing issue outlines the disrepancy, and that seems to be where discussion is happening.

Partial encapsulation means some things bleed through - CSS Custom Properties (variables) pierce the Shadow DOM boundary along with inheritable CSS properties (mainly typographic, list formatting, and general visibility) which may be useful (e.g. for making your component match the container page’s typographic styling automatically) or a source of subtle conflicts. Note: just setting all: initial may not be sufficient to decouple your Web Component from its parent page’s CSS.

The @property issue: #

Tailwind 4’s compiled CSS makes heavy use of CSS Custom Properties like:

@property --tw-font-weight {
  syntax: '*';
  inherits: false;
}
@property --tw-tracking {
  syntax: '*';
  inherits: false;
}
@property --tw-shadow {
  syntax: '*';
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
  syntax: '*';
  inherits: false;
}
@property --tw-shadow-alpha {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 100%;
}

So what can be done about this? You could opt to use Tailwind 3, which uses CSS Variables but does not rely on @property. Or you could use an iframe instead of a Web Component, which avoids the Shadow Root limitation. But both of these options impose other fairly significant limitations and so, for our needs, were not viable alternatives. Instead, we opted to solve the problem by post-processing the compiled CSS to eliminate the dependency on @property.

The fix: #

A naïve approach would be to use string methods to manipulate the compiled CSS content (i.e. regex) but we know that’s brittle and difficult to maintain. Instead we opted to build a small PostCSS transform. If you’re unfamiliar, PostCSS is a tool that transforms CSS using JavaScript plugins. What makes it so powerful is that those plugins operate on an AST (abstract syntax tree), not on the string content of the CSS file, which allows for richer and more precise edits.

The plugin module is about 300 lines. View the whole thing here. Included in the gist is the small vite plugin wrapper that registers this as a PostCSS plugin within vite’s pipeline.

The general idea is that we need to transform rules like this:

@property --tw-translate-x {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}

@property --tw-border-style {
  syntax: "*";
  inherits: false;
  initial-value: solid;
}

into this:

:host, :host::before, :host::after,
*, :before, :after, ::backdrop, ::file-selector-button {
  --tw-translate-x: 0;
  --tw-border-style: solid;
}

In reality, it’s a bit more complicated. The plugin must:

  • Derive concrete defaults from Tailwind @property rules. Tailwind registers many —tw-* variables with initial-value, but registration alone is not a reliable runtime fallback inside a shadow root. The plugin turns those registrations into ordinary declarations so shadow-scoped styles have actual values to read.
  • Ignore non-Tailwind and inheriting custom properties. The plugin only materializes defaults for Tailwind-owned —tw-* properties that are meant to be non-inheriting. That prevents it from rewriting unrelated custom properties or changing the behavior of variables Tailwind expects to inherit.
  • Reuse Tailwind’s own reset-rule values when they are more accurate than @property. Some Tailwind variables either omit initial-value or use a reset declaration that differs from the registration. The plugin reads those reset rules too, so the shadow fallback matches Tailwind’s real runtime defaults instead of an incomplete approximation.
  • Prefer reset-rule values over @property values when both exist. Tailwind’s emitted reset CSS is the most concrete source of truth for what the utilities expect. When there is a mismatch, the plugin chooses the reset value so utilities like rings, shadows, and border styles behave the same inside and outside shadow DOM.
  • Expand Tailwind’s universal fallback selectors to cover the shadow host and special pseudos. Tailwind’s built-in reset targets descendants like *, :before, and :after, but shadow-root consumers also need :host, :host::before, :host::after, ::backdrop, and ::file-selector-button. Without that expansion, some variables stay unset on the actual shadow host or on pseudo-elements that Tailwind utilities style.
  • Inject an unconditional fallback rule in @layer properties. Tailwind may place its reset behind nested feature checks such as @supports, which means the defaults are not guaranteed to exist everywhere the shadow stylesheet runs. The plugin adds a plain host-scoped fallback rule so the shadow tree always gets baseline —tw-* values.
  • Keep the transform idempotent. The plugin detects an existing synthesized fallback rule and merges into it instead of duplicating it. That matters because Vite/PostCSS pipelines can pass over CSS more than once, and the output needs to stay stable rather than growing duplicate reset blocks.

This wasn’t the only hurdle we had to overcome with this particular blend of ingredients, but it was one of the more surprising and nuanced issues we ran into.

Our second problem: “Where are my fonts?” #

Out of the box, @font-face wasn’t registering web fonts correctly (when declared in the stylesheet we were attaching to the Shadow Root). So I turned to google for the bad news.

@font-face definitions in shadowRoot cannot be used within the shadowRoot

No, your eyes do not deceive you. That’s an open “feature request” from 2014 that apparently requires changes (still a draft proposal) to the CSS Specification in order to resolve. So for the foreseeable future it seems we are left with the kinds of hacks and workarounds mentioned in that thread (@font-face rules must be declared on the root document if you want to use them in your Web Component). Not great, Bob.

Solving this problem generically across an organization can be a tremendous challenge. Viable solutions depend on whether you are in control of only the embeddable widget or both the widget and the host application (the web property that is embedding your widget). If you control both, then a universal server-side workaround could in theory be devised. In our case, we did not have much agency over the host application so we had get creative and handle it strictly on the widget side by attaching fonts to the root document at runtime.

What we did: #

We built a small font loader module that uses the FontFace API to load a few select web fonts and attaches them to the parent document, taking care not to duplicate registrations (in case more than one widget is ever rendered on the same page). Note that out of the box vite transforms the .woff2 imports into asset URLs, which is what we need for this to work.

import openSansSemiBoldWoff2 from '../assets/fonts/open-sans-v44-latin-600.woff2';
import openSansRegularWoff2 from '../assets/fonts/open-sans-v44-latin-regular.woff2';

const FONT_DEFINITIONS = [
  {
    key: 'open-sans-400',
    family: 'Open Sans',
    source: openSansRegularWoff2,
    weight: '400',
  },
  {
    key: 'open-sans-600',
    family: 'Open Sans',
    source: openSansSemiBoldWoff2,
    weight: '600',
  },
] as const;

const fontRegistrations = new WeakMap<Document, Promise<void>>();
const registeredFonts = new WeakMap<Document, Set<string>>();

/**
 * Registers widget fonts at the document level so Shadow DOM consumers can use
 * them without relying on shadow-scoped @font-face rules.
 */
export function ensureFontFaces(root: Document | ShadowRoot) {
  const targetDocument = root instanceof Document ? root : root.ownerDocument;
  const existingRegistration = fontRegistrations.get(targetDocument);

  if (existingRegistration) {
    return existingRegistration;
  }

  const registration = registerFontFaces(targetDocument).catch((error) => {
    fontRegistrations.delete(targetDocument);
    throw error;
  });

  fontRegistrations.set(targetDocument, registration);

  return registration;
}

async function registerFontFaces(targetDocument: Document) {
  if (typeof FontFace === 'undefined' || !('fonts' in targetDocument)) {
    return;
  }

  const fontFaceSet = targetDocument.fonts;
  const loadedFontKeys =
    registeredFonts.get(targetDocument) ?? new Set<string>();
  const pendingFonts = FONT_DEFINITIONS.filter(
    ({ key }) => !loadedFontKeys.has(key),
  );

  if (pendingFonts.length === 0) {
    return;
  }

  const loadedFonts = await Promise.all(
    pendingFonts.map(({ family, key, source, weight }) => {
      const fontFace = new FontFace(family, `url(${source}) format('woff2')`, {
        display: 'swap',
        style: 'normal',
        weight,
      });

      return fontFace.load().then((loadedFont) => ({ key, loadedFont }));
    }),
  );

  for (const { key, loadedFont } of loadedFonts) {
    fontFaceSet.add(loadedFont);
    loadedFontKeys.add(key);
  }

  registeredFonts.set(targetDocument, loadedFontKeys);
}

Should you do this? #

My heart says “no”. We should just require the parent application load the necessary web fonts and specify typographic styles for widgets to inherit (font-family is one of the rules that can pierce the shadow boundary, allowing widgets to take on the typographic styles of the parent application). In practice, this isn’t the easiest thing to mandate especially if different teams (or companies) are building parent and widget. It’s not difficult to imagine cases where the visual design is compromised (visually) by improper font loading on the parent side and I know many designers (or design-minded folks, myself included) who wouldn’t be too happy about that.

Without web fonts, a widget isn’t truly fully encapsulated.

Closing thoughts #

Web Components are a great tool to have in our kits. They provide good encapsulation for standalone widgets. However, current browser limitations around Shadow Root, CSS Custom Properties, and @font-face require specific workarounds with different trade-offs.

The PostCSS solution is rather elegant, but runtime font loading is clunky and (possibly) unnecessary. If you have the option, just have the parent application own the font stack and typographic styling and rely on CSS inheritance for the embedded widget.

Solomon Hawk

Solomon is a Development Director in our Durham, NC, office. He focuses on JavaScript development for clients such as ID.me. He loves distilling complex challenges into simple, elegant solutions.

More articles by Solomon

Related Articles