Using with React
There are two ways to use the components in React:
- Plain tags. React 19 supports custom elements directly. Write
<rc-select>in JSX and reference the generated JSX typings. - Wrappers.
@rcarls/rc-reactprovides one React component per element. Each wrapper registers its element, takes typed props andonevent props in camel case, and also works in React 18.
Both keep the native child the component enhances, so forms, labels, and pre-upgrade behavior work the same either way.
Plain tags (React 19)
Register the elements you use once in your application entry point. Prefer the aggregate package's per-component subpaths so unused components stay out of the bundle:
import '@rcarls/rc-webcomponents/rc-dialog/define';
import '@rcarls/rc-webcomponents/rc-select/define';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(<App />);
Add the JSX typings once, for example in vite-env.d.ts:
/// <reference types="vite/client" />
/// <reference types="@rcarls/rc-webcomponents/react" />
The typings are generated from each component's source. They cover every attribute and
property, type each element's custom events, and accept the components' CSS custom
properties in style objects.
Attributes and properties
React 19 sets a prop as a property when the element has one by that name, and as an attribute otherwise. Use the kebab-case attribute for anything that should appear in server-rendered markup, and the camel-case property for rich values such as arrays:
<rc-select popup-mode="dialog" value={['draft']}>
<select name="status" aria-label="Status" multiple>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</rc-select>
Events
React 19 attaches an on<event-name> prop as a listener for exactly that event, so custom
events need no useEffect. The handler receives the typed event:
<rc-select onrc-select-change={(event) => console.log(event.detail.value)}>
<select name="status" aria-label="Status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</rc-select>
Each component's page documents its events and the shape of event.detail.
Refs and methods
Type refs with the element's class, imported from its package or the aggregate subpath:
import { useRef } from 'react';
import type { RCDialog } from '@rcarls/rc-webcomponents/rc-dialog';
function ConfirmDialog() {
const ref = useRef<RCDialog>(null);
return (
<>
<button type="button" onClick={() => ref.current?.showModal()}>
Open
</button>
<rc-dialog ref={ref} movable resize="both">
<dialog aria-labelledby="confirm-title">
<strong id="confirm-title">Confirm</strong>
<button type="button" onClick={() => ref.current?.close('ok')}>
OK
</button>
</dialog>
</rc-dialog>
</>
);
}
Wrappers (@rcarls/rc-react)
yarn add @rcarls/rc-react @rcarls/rc-webcomponents lit
Import each wrapper from the subpath named after its component package. The module registers the element when it loads:
'use client';
import { RcSelect } from '@rcarls/rc-react/rc-select';
export function StatusPicker() {
return (
<RcSelect popup-mode="dialog" onRcSelectChange={(event) => console.log(event.detail.value)}>
<select name="status" aria-label="Status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</RcSelect>
);
}
Wrapper refs resolve to the element instance, typed as its class. Event props are the event
name in camel case: rc-select-change becomes onRcSelectChange.
Server rendering and Next.js
Every package imports safely on the server, so client components that use the wrappers or
import a define entry render in the Next.js App Router without dynamic imports. During a
server render:
- A wrapper outputs its tag, its attributes, and its children. Props that are element properties are set in the browser after hydration, so pass the attribute form for state the server markup needs.
- Plain tags from a React Server Component render as written and upgrade once their
definitions load. Render
RcRegisteronce in the root layout with every such tag:
import { RcRegister } from '@rcarls/rc-react';
import './globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<RcRegister tags={['rc-app-bar', 'rc-card', 'rc-chip']} />
</body>
</html>
);
}
RcRegister loads each definition as its own chunk after hydration, so React matches the
server markup before any element upgrades and adds its own attributes.
To keep the native markup laid out before upgrade, load the static base and pre-upgrade
stylesheets. With Tailwind CSS, also import @rcarls/rc-theme-tailwind/layers.css first so
Preflight cannot override component structure. The server rendering guide covers
both.
Declarative shadow DOM with React
By default the server sends each element's light DOM only, and the element renders its shadow
DOM once its definition loads. To send the shadow DOM in the server HTML as well, use
@lit-labs/ssr-react. This works with
the @rcarls/rc-react wrappers and with plain tags, and was verified with Next.js 16 and
Turbopack:
- Import
@lit-labs/ssr-react/enable-lit-ssr.jsfirst in a client component that renders the elements, and import the element definitions there too. On the server it patches React's element creation to add each element's declarative shadow root; in the browser it loads Lit's hydration support. - Render the elements from client components only. A server component's output reaches the
browser twice, as HTML and as React's server component payload, and the payload still
holds the
<template>the browser has already turned into a shadow root. React then reports a hydration error and renders the whole tree again. - Leave
jsxImportSourceat its default. Setting it to@lit-labs/ssr-reactas well as importing the patch renders each shadow root twice, and the package's JSX runtime declares no JSX types. @lit-labs/ssr-react0.3.4 joins the server output withjoin('')in development mode, which writes Lit's lazy render functions into the HTML as text. Until it is fixed upstream, patchlib/node/wrap-jsx.jsto usecollectResultSync(shadowContents), as its production path does.- Put event handlers such as
onClickon the native child. On a wrapper, the server writes the function's source into anonclickattribute. - React's development build warns that attributes such as
orientationorhas-titlediffer from the server HTML: the elements write them on their hosts. AddsuppressHydrationWarningto an element to silence it; production is unaffected.
React Router
Declarative shadow DOM also works in React Router's framework mode, verified with React Router 8.5 and Vite 8. React Router has no server components by default, so every route module renders on the server and hydrates in the browser. The setup differs from Next.js in three places:
- Bundle React into the server build. React Router's server build imports React from
node_modules, and Node gives a module that names an import from a CommonJS package, such asjsxfromreact/jsx-runtime, the value it had when the package loaded. The patch fromenable-lit-ssr.jsreplaces that function afterward, so plain tags render without shadow roots; wrappers still get them, because they callReact.createElementon React's default export. Bundling every dependency into the production server build makes the patch apply to all of them. Bundle all of them rather than a list: a package left out, such as@lit/react, loads a second copy of React. - Register the elements on the server in
entry.server.tsx, after the patch, and in the browser withRcRegister. Adefineimport in a route module also runs in the browser before React hydrates, and React's development build then reports the attributes the elements wrote on their native children, such asrole="switch"or a page link'saria-label. - Load hydration support from the root module. In the browser,
enable-lit-ssr.jsloads Lit's hydration support, which must run before any element definition.
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig(({ command }) => ({
// Bundle React with the server build so the Lit patch reaches the JSX runtime.
ssr: command === 'build' ? { noExternal: true } : {},
plugins: [reactRouter()],
}));
import '@lit-labs/ssr-react/enable-lit-ssr.js';
import '@rcarls/rc-webcomponents/rc-chip/define';
import '@rcarls/rc-webcomponents/rc-switch/define';
// ...the rest of the default entry (`npx react-router reveal entry.server`)
import '@lit-labs/ssr-react/enable-lit-ssr.js';
import { RcRegister } from '@rcarls/rc-react';
import { Links, Meta, Scripts, ScrollRestoration } from 'react-router';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
{children}
<RcRegister tags={['rc-chip', 'rc-switch']} />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
The development server keeps its default externals, because it cannot run React's CommonJS
modules once they are bundled. In development, wrappers still render their shadow roots on the
server and plain tags render theirs in the browser, so the page is the same after hydration.
The @lit-labs/ssr-react 0.3.4 development-mode bug described above does not appear, since
the development server never reaches the patched JSX runtime.