Replacing React Islands with Web Components

Islands architecture was a big step forward for static sites: instead of hydrating the whole page, only the interactive parts ship JavaScript. But an island still carries its framework. A "copy code" button written as a React island on an otherwise static documentation page pulls in React and React DOM — around 45 KB compressed — to attach one click handler. Multiply that by tabs, a theme toggle and a feedback widget, and a page that is 95% text ends up with a 120 KB JavaScript budget and a noticeable INP cost on low-end phones.

For small, self-contained widgets, a custom element does the same job with no runtime. This guide shows how to decide which islands to replace, rewrites a tabs widget as a web component that enhances build-time HTML, and measures the difference. It is part of JavaScript Hydration & Partial Rendering.

Prerequisites

  • A static site using Astro islands, or a framework-hydrated widget in another generator.
  • A bundle analysis showing which scripts each page loads — see Tracking Bundle Size Per Pull Request.
  • Field INP data if you have it, to confirm the benefit afterwards.

Which Islands to Replace

Not every island is a candidate. A search interface with complex state, a data-heavy chart or a form with validation is where a framework earns its keep. The good candidates are the small widgets that make up most islands on content sites.

Which islands are worth replacing A two by two grid by state complexity and whether other framework code is on the page. Simple widgets on pages with no other framework code are the best candidates: copy buttons, tabs, disclosure, theme toggle. Complex widgets such as search or charts should stay as islands. Simple widgets on pages that already load the framework give a smaller gain. Replace simple islands first Replace now copy button, tabs, disclosure, theme toggle, reading progress saves the whole runtime Keep as island search UI, charts, multi-step forms framework earns its cost Replace later simple widget on a page that already loads React saves only the widget code Leave alone complex widget sharing state with other islands simple, local state complex or shared state no other framework framework already loaded
The biggest saving comes when the last React island on a page is removed, because the runtime goes with it.

List every island by page template, note its framework, and mark the templates where replacing the simple ones removes the framework entirely. Those are the pages where the saving is largest. On a typical documentation site that is the article template, which makes up most of the page views.

Build-Time HTML, Client-Side Behaviour

The key design choice is to render the widget's markup at build time and let the custom element add behaviour. That keeps the content visible without JavaScript, avoids layout shift, and means the element needs no rendering code at all — just event handlers.

Here is a tabs component in Astro that renders ordinary HTML and wraps it in a <tab-set> element:

---
const { labels } = Astro.props;
---
<tab-set>
  <div role="tablist">
    {labels.map((label, i) => (
      <button role="tab" id={`tab-${i}`} aria-controls={`panel-${i}`}
              aria-selected={i === 0 ? 'true' : 'false'} tabindex={i === 0 ? 0 : -1}>{label}</button>
    ))}
  </div>
  <slot />
</tab-set>

<script>
  class TabSet extends HTMLElement {
    connectedCallback() {
      this.tabs = [...this.querySelectorAll('[role="tab"]')];
      this.panels = [...this.querySelectorAll('[role="tabpanel"]')];
      this.addEventListener('click', (e) => {
        const tab = e.target.closest('[role="tab"]');
        if (tab) this.select(this.tabs.indexOf(tab));
      });
      this.addEventListener('keydown', (e) => {
        const i = this.tabs.indexOf(document.activeElement);
        if (i < 0) return;
        if (e.key === 'ArrowRight') this.select((i + 1) % this.tabs.length, true);
        if (e.key === 'ArrowLeft') this.select((i - 1 + this.tabs.length) % this.tabs.length, true);
      });
    }
    select(index, focus = false) {
      this.tabs.forEach((t, i) => {
        t.setAttribute('aria-selected', String(i === index));
        t.tabIndex = i === index ? 0 : -1;
        this.panels[i].hidden = i !== index;
      });
      if (focus) this.tabs[index].focus();
    }
  }
  customElements.define('tab-set', TabSet);
</script>

Astro bundles the <script> once per page, however many <tab-set> elements there are, and loads it as a module, which is deferred by default. The same pattern works in Hugo or Eleventy: render the markup in a partial or shortcode and include the script once in the layout.

Light DOM or Shadow DOM

Custom elements can render into a shadow root, which encapsulates styles and markup. For content-site widgets that is usually unnecessary and sometimes harmful: shadow DOM hides the markup from your site's global styles, and ARIA attributes such as aria-labelledby cannot reference IDs across the shadow boundary. Enhancing light DOM children, as above, keeps styling in your normal CSS and keeps accessibility relationships intact. Reach for shadow DOM when you are building a widget to be embedded on other people's sites, where isolation is the point.

Light DOM enhancement compared with shadow DOM rendering Left: a light DOM element wraps build-time buttons and panels; site CSS applies and aria-controls references work. Right: a shadow DOM element holds its markup inside a shadow root; site CSS does not reach it and ID references from outside cannot cross the boundary. Where the widget's markup lives Light DOM (recommended) <tab-set> button role=tab (build-time HTML) div role=tabpanel </tab-set> Shadow DOM <tab-set> #shadow-root: tabs, panels rendered by JavaScript </tab-set> site CSS applies, ARIA IDs resolve, content visible before JavaScript styles isolated, ID references blocked, content appears when script runs
For content-site widgets, enhancing build-time light DOM keeps styling and accessibility simple.

Declarative shadow DOM, where the shadow root is written into the HTML with <template shadowrootmode="open">, removes the "content appears when the script runs" problem and is supported in all current browsers. It still isolates styles, though, so it suits design-system components more than one-off enhancements on a documentation page.

What the Browser Does Differently

A React island has to download the runtime and the component, execute them, and hydrate — re-render the component in memory and reconcile it with the server HTML — before it responds to clicks. A custom element's script runs once, defines the class, and the browser calls connectedCallback for each instance. There is no reconciliation step and far less main-thread work.

Main-thread work to make three widgets interactive On a mid-range phone, React islands for tabs, copy buttons and a theme toggle need 46 kilobytes of compressed JavaScript and 190 milliseconds of main-thread work including hydration. The same three widgets as custom elements need 2.1 kilobytes and 9 milliseconds. Three widgets, mid-range Android phone React islands: bytes 46 KB custom elements: bytes 2.1 KB React islands: main thread 190 ms custom elements: main thread 9 ms compressed transfer size; script evaluation plus hydration, 4× CPU slowdown profile
Most of the saving is the framework runtime and hydration, not the widget code itself.

Migrating One Widget at a Time

Replace islands incrementally so each change is small and measurable:

  1. Pick the widget with the most instances on the highest-traffic template. Copy buttons on code blocks are often first.
  2. Move rendering to build time. The markup that React rendered on the server becomes a plain component or partial.
  3. Write the element with only the event handling and state the widget needs.
  4. Match behaviour exactly, including keyboard support and focus handling, and run the same accessibility checks as before.
  5. Remove the island and confirm in the bundle report that the framework chunk is gone from that template.

Test each replacement on the slowest device you support, not only on a developer laptop. The difference between a framework island and a custom element is small on a fast machine and large on a budget phone, and the budget phone is where your INP p75 comes from. A Lighthouse run with the default mobile throttling, or a WebPageTest run on a Moto G-class device, shows the change in Total Blocking Time within minutes.

Keep the old component until the new one has been live for a release. If a regression appears, switching the import back is a one-line change.

Measured Impact

On a 2,300-page documentation site built with Astro, the article template had three React islands: code-copy buttons, language tabs and a theme toggle. Replacing all three with custom elements removed React from the template. JavaScript on article pages fell from 118 KB to 6 KB compressed, Total Blocking Time in lab tests on a throttled phone dropped from 310 to 20 milliseconds, and field INP p75 on mobile improved from 240 to 110 milliseconds over the following month. The search page kept its React island, and the framework still loads there.

Pitfalls & Rollback

  • Rendering in JavaScript. If the element builds its markup in connectedCallback, content appears late and shifts layout; render at build time.
  • Defining the element twice. Calling customElements.define for an existing name throws; guard with customElements.get if scripts may load twice.
  • Forgetting keyboard behaviour. Frameworks' component libraries often handled it for you; test arrow keys, Home, End and focus order.
  • Shared state between widgets. If two widgets must stay in sync, use DOM events or a tiny store; if that gets complex, the island may be the better choice.
  • Rollback: restore the island import; the build-time markup is compatible with both.

Conclusion

Most interactive widgets on static content sites are small and self-contained, and for those a framework island is a heavy vehicle. Rendering the markup at build time and enhancing it with a custom element keeps the progressive-enhancement benefits of islands while dropping the runtime and hydration cost. Replace the simple widgets on your busiest template first, confirm the framework chunk disappears, and measure INP — the results are usually large for a small amount of code.

FAQ

When is a web component better than a React island?

When the widget is small and self-contained — a copy button, tabs, a theme toggle, a disclosure — and the page has no other React. The component then ships a few hundred bytes of JavaScript instead of the 45 KB or so of compressed React and React DOM runtime plus the island's own code.

Do web components work with Astro and other static site generators?

Yes. A custom element is plain HTML plus a script, so it works in Astro, Hugo, Eleventy, Jekyll or any generator. In Astro, a script tag in a component is bundled and deduplicated automatically, so the element's class is defined once per page however many instances there are.

Are web components accessible?

They are as accessible as the HTML you put in them. Enhancing server-rendered buttons, links and headings keeps native semantics and keyboard behaviour; shadow DOM does not block screen readers, but labels and ARIA references cannot cross the shadow boundary, so light DOM is simpler for most widgets.

Do I lose server-side rendering with custom elements?

No, if you render the markup at build time and have the element enhance it. The HTML is visible before any JavaScript runs, which is the same progressive-enhancement model as an island, without a framework hydration step.