Web Components are great for distribution: ship one <my-widget> tag and it works in plain HTML, Laravel Blade, Rails, WordPress, or inside another SPA.
React is great for building UIs quickly.
So… why not build your UI in React and ship it as a Web Component?
That’s exactly what R2WC (@r2wc/react-to-web-component) does: it wraps a React component and registers it with customElements.define(...).
In this post, we’ll build two real components:
-
<r2wc-counter>— a typed counter widget (string/number/boolean/json props) -
<confirm-dialog>— a confirmation modal that emits native DOM events (confirm/cancel)
Why React → Web Components ?
You get:
- Framework-agnostic reuse: use the same widget in React, Vue, Blade, Astro, etc.
- Clean integration: Web Components speak “DOM” (attributes, properties, events)
- Controlled surface: expose a small prop/event API instead of shipping a whole app
Tradeoff: you still ship React inside the bundle. It’s not “React-free” — it’s “React encapsulated”.
Setup (Vite + React + TypeScript)
npm create vite@latest r2wc-demo -- --template react-ts
cd r2wc-demo
npm i
npm i @r2wc/react-to-web-component
npm run dev
Your goal is simple:
- Build React components
- Convert them into Web Components
- Use them from
index.html(no React needed)
Example 1 — <r2wc-counter> (props: string/number/boolean/json)
1) The React component
Create src/Counter.tsx:
import React, { useMemo, useState } from "react";
export type CounterConfig = {
step?: number;
max?: number;
};
export type CounterProps = {
label: string; // string
start: number; // number
disabled: boolean; // boolean
config?: CounterConfig; // json (passed as JSON string in HTML)
onChange?: (value: number) => void; // function (passed via JS property)
};
export function Counter({
label,
start,
disabled,
config,
onChange,
}: CounterProps) {
const step = config?.step ?? 1;
const max = config?.max ?? 999;
const [count, setCount] = useState(start);
const canInc = useMemo(
() => !disabled && count + step <= max,
[disabled, count, step, max]
);
function inc() {
if (!canInc) return;
const next = count + step;
setCount(next);
onChange?.(next);
}
return (
<div
style={{
fontFamily: "system-ui",
padding: 12,
border: "1px solid #ddd",
borderRadius: 12,
}}
>
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
<div style={{ fontSize: 28, margin: "8px 0" }}>{count}</div>
<button onClick={inc} disabled={!canInc}>
+{step}
</button>
{disabled && (
<span style={{ marginLeft: 8, opacity: 0.7 }}>(disabled)</span>
)}
</div>
);
}
2) Convert it to a Web Component
Create src/web-components.ts:
import r2wc from "@r2wc/react-to-web-component";
import { Counter } from "./Counter";
customElements.define(
"r2wc-counter",
r2wc(Counter, {
props: {
label: "string",
start: "number",
disabled: "boolean",
config: "json",
onChange: "function",
},
shadow: "open", // optional but nice for encapsulation
})
);
3) Register it from the entry point
Replace src/main.tsx with:
import "./web-components";
4) Use it in index.html (no React)
Update index.html (Vite root):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>R2WC demo</title>
</head>
<body>
<h1>R2WC Counter (vanilla HTML)</h1>
<r2wc-counter
id="counter"
label="Downloads"
start="3"
disabled="false"
config='{"step":2,"max":10}'
></r2wc-counter>
<!-- Loads the file that registers custom elements -->
<script type="module" src="/src/main.tsx"></script>
<script type="module">
const el = document.getElementById("counter");
// Pass functions via JS properties (not HTML attributes)
el.onChange = (value) => {
console.log("[r2wc-counter] new value:", value);
};
</script>
</body>
</html>
✅ You’ve built a React component and used it as a native HTML tag.
Example 2 — <confirm-dialog> (native DOM events)
This one is very handy for integration: a confirmation modal that dispatches real DOM events.
1) The React component
Create src/ConfirmDialog.tsx:
import React from "react";
type ConfirmDialogProps = {
open: boolean;
title: string;
message: string;
// R2WC will provide these via `events`
onConfirm?: (payload: { at: string }) => void;
onCancel?: (payload: { at: string }) => void;
};
export function ConfirmDialog({
open,
title,
message,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!open) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
display: "grid",
placeItems: "center",
background: "rgba(0,0,0,0.45)",
zIndex: 9999,
}}
>
<div
style={{
width: 420,
background: "white",
borderRadius: 12,
padding: 16,
}}
>
<h3 style={{ margin: "0 0 8px" }}>{title}</h3>
<p style={{ margin: "0 0 16px" }}>{message}</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
type="button"
onClick={() => onCancel?.({ at: new Date().toISOString() })}
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm?.({ at: new Date().toISOString() })}
>
Confirm
</button>
</div>
</div>
</div>
);
}
2) Wrap it with R2WC + declare events
Create src/confirm-dialog.wc.ts:
import r2wc from "@r2wc/react-to-web-component";
import { ConfirmDialog } from "./ConfirmDialog";
const ConfirmDialogElement = r2wc(ConfirmDialog, {
props: {
open: "boolean",
title: "string",
message: "string",
},
// When React calls onConfirm(payload),
// R2WC dispatches: new CustomEvent("confirm", { detail: payload, ... })
events: {
onConfirm: { bubbles: true, composed: true },
onCancel: { bubbles: true, composed: true },
},
shadow: "open",
});
customElements.define("confirm-dialog", ConfirmDialogElement);
3) Register both components
Update src/web-components.ts to include both:
import r2wc from "@r2wc/react-to-web-component";
import { Counter } from "./Counter";
import { ConfirmDialog } from "./ConfirmDialog";
customElements.define(
"r2wc-counter",
r2wc(Counter, {
props: {
label: "string",
start: "number",
disabled: "boolean",
config: "json",
onChange: "function",
},
shadow: "open",
})
);
customElements.define(
"confirm-dialog",
r2wc(ConfirmDialog, {
props: {
open: "boolean",
title: "string",
message: "string",
},
events: {
onConfirm: { bubbles: true, composed: true },
onCancel: { bubbles: true, composed: true },
},
shadow: "open",
})
);
4) Use it in index.html + listen to native events
Add this to your index.html:
<button id="open">Open dialog</button>
<confirm-dialog
id="dlg"
title="Delete item?"
message="This action cannot be undone."
></confirm-dialog>
<script type="module">
const dlg = document.getElementById("dlg");
const openBtn = document.getElementById("open");
// Prefer JS properties for booleans
dlg.open = false;
openBtn.addEventListener("click", () => {
dlg.open = true;
});
dlg.addEventListener("confirm", (e) => {
console.log("CONFIRM payload:", e.detail);
dlg.open = false;
});
dlg.addEventListener("cancel", (e) => {
console.log("CANCEL payload:", e.detail);
dlg.open = false;
});
</script>
✅ Now your React modal integrates like a native element with native DOM events.
Common gotchas (save yourself the pain)
1) Boolean attributes are tricky
In HTML, open="false" is still a present attribute and can behave like true.
Best practice: set booleans via JS properties:
dlg.open = true;
dlg.open = false;
Or manipulate attributes explicitly:
dlg.setAttribute("open", "");
dlg.removeAttribute("open");
2) Pass functions and complex objects via JS properties
-
functionprops: JS property only -
jsonprops: okay as an attribute, but keep it simple (or set via property)
3) Shadow DOM affects styling
Shadow DOM is great for encapsulation, but global CSS won’t reach inside.
If you need theming, consider:
- CSS variables
- explicit “theme” props
- or disable shadow DOM (
shadow: false) and style globally
When this approach shines
Use React → Web Components when you need:
- a widget embedded in multiple stacks
- a shared UI component you can ship once
- a clean boundary (props/events) between host app and UI
Avoid it when:
- you need SSR/hydration from the host framework
- bundle size is extremely constrained
- you need deep integration with host router/state
Conclusion
With R2WC, you can:
- design components in React (fast iteration)
- ship them as Web Components (portable + framework-agnostic)
- integrate with typed props and native DOM events
Top comments (0)