Skip to content

Turning a React Component into a Plain Web Component

Adityo Guni Waluyo

The group-network section got split out of about-teaser, then went portable: rebuilt as a shadow-DOM custom element any page can drop in.

TL;DR

Refactoring a React section into a standalone file raised a portability problem: non-React teams couldn't reuse it without shipping a framework bundle. Copy-pasting rendered HTML failed because utility styles depended on the build pipeline and manual hover handling was brittle. Rebuilding it as an autonomous web component with shadow DOM and a template made it lightweight and portable.

I was refactoring a corporate website project recently, splitting the network section out of about-teaser.tsx into its own group-network.tsx file. It looked clean on the surface: a React component featuring an eyebrow label, a bold heading, and a precise 3+4 grid of company cards. The NetworkCard subcomponent handled its own hover translate and accent states. But then a practical question hit me. What if a non-React team needs to embed this exact same section on their marketing site or internal documentation? Handing them a React bundle for one isolated section felt like overkill.

My first instinct was lazy. I figured I could just copy-paste the rendered JSX output into a plain script tag, wrap it in some vanilla JavaScript, and call it a day. I imagined dropping the raw HTML string into the DOM and attaching a few event listeners to handle the hover effects. It felt quick, harmless, and perfectly sufficient.

It was not. The moment I tried to isolate the styles, I realized the utility classes were bleeding or missing entirely. They relied heavily on the parent document build pipeline and global CSS variables. Worse, managing the lifecycle of those hover states and grid renders without a virtual DOM felt like reinventing the wheel poorly. I was fighting the browser instead of working with it. Manually querying the DOM to toggle classes on mouseenter and mouseleave was brittle and prone to memory leaks if not cleaned up properly.

The real solution was to rebuild the section as a framework-free web component: . This meant crossing the boundary from JSX props to HTML attributes and light DOM children.

The architecture shift

Web components combine custom elements, shadow DOM, and templates for encapsulated reuse [9]. Instead of relying on a framework runtime to manage state and rendering, the browser handles the lifecycle natively.

I had to extend HTMLElement to create an autonomous custom element. The naming rule here is strict: the name must be lowercase with a hyphen, making perfectly valid, unlike React components which have no such naming constraints [10].

I initially wondered about using customized built-in elements, like , to preserve semantic HTML and inherit native behaviors. That was a dead end. Safari does not plan to support customized built-in elements [10]. This browser reality forced the autonomous route, making HTMLElement the only reliable base class for cross-browser compatibility.

Lifecycle and encapsulation

The mental model shift here is subtle but critical. Unlike React mount-once behavior, connectedCallback fires each time the element is added to the document [10]. If a framework or script moves the node around, this callback can fire multiple times. You have to design your initialization logic to be idempotent, checking if the shadow root already exists before rebuilding it to avoid duplicate event listeners or DOM nodes.

Shadow DOM hides internals from page JS and CSS, and open mode exposes the shadowRoot [11]. This is exactly where the component styles had to go. Instead of relying on global utility classes, the CSS ships directly inside the shadow root style sheet. Open mode is not a strong security mechanism, as browser extensions can evade it [11], but for structural encapsulation and preventing style collisions, it is more than enough.

Rendering the template

In React, you return JSX. In a web component, you rely on the