Skip to main content
Version: v4.5.0

Creating Custom Elements

When building applications with webbloqs, you may need to create your own custom web components that wrap React components. This is useful when you need:

  • Server-side rendering via Lit SSR
  • Form integration via Form-Associated Custom Elements (FACE)
  • Framework-agnostic distribution as standard web components
  • Shadow DOM encapsulation for style isolation

Webbloqs provides this through @webbloqs/proxies, which bridges React components to custom elements.

Three-Layer Architecture​

Consumer projects use a three-layer pattern to create custom elements from React components:

LayerFileResponsibility
1. React ComponentMyComponent.tsxPure React — uses webbloqs components from @webbloqs/react
2. CustomElementMyComponentElement.tsxWraps the React component with CustomElement() factory from @webbloqs/proxies
3. React Wrapperreact.tsxRegisters the custom element and re-exports it as a typed React component via createComponent() — so consumers can use it in JSX with full prop types and IDE autocompletion

Each layer has a distinct responsibility. Let's walk through a real example.

Step-by-step Example​

Layer 1: React Component​

Write a standard React component using webbloqs components. This is pure React — no web component concerns here.

// MyComponent.tsx
import { Button } from "@webbloqs/react/lib/button";
import { Stack } from "@webbloqs/react/lib/stack";
import { TextField } from "@webbloqs/react/lib/text-field";

export interface MyComponentProps {
label: string;
onSubmit: () => void;
}

export const MyComponent = ({ label, onSubmit }: MyComponentProps) => (
<Stack space="space--l">
<TextField name="email" label={label} type="email" required />
<Button kind="primary" onClick={onSubmit}>Submit</Button>
</Stack>
);

Layer 2: CustomElement Definition​

Wrap the React component with the CustomElement factory from @webbloqs/proxies. This defines the web component's properties, stylesheets, and optional form association.

// MyComponentElement.tsx
import { CustomElement } from "@webbloqs/proxies";
import { MyComponent, MyComponentProps } from "./MyComponent";
import Styles from "./my-component.scss";

export const MyComponentElement = CustomElement<MyComponentProps>({
reactComponent: MyComponent,
stylesheets: [Styles],
properties: {
label: String,
onSubmit: Function,
},
formAssociated: true, // Optional: enables form participation
});

The properties object maps component props to their types. The CustomElement factory handles:

  • Property/attribute synchronization
  • Shadow DOM rendering
  • Stylesheet adoption
  • SSR support via Lit SSR

Shadow DOM rendering and stylesheet adoption both assume the default exposed: false. See Exposed Elements for what changes when they do not apply.

For the full CustomElement API, see the @webbloqs/proxies documentation.

Layer 3: React Wrapper​

Register the custom element and create a React wrapper using createComponent. The registration guard prevents errors when the same element is registered twice.

// react.tsx
import React from "react";
import { createComponent } from "@webbloqs/react/lib/create-component";
import { MyComponentElement } from "./MyComponentElement";

if (customElements.get("my-org-my-component") === undefined) {
customElements.define("my-org-my-component", MyComponentElement);
}

export const MyComponent = createComponent({
react: React,
tagName: "my-org-my-component",
elementClass: MyComponentElement,
});

This React wrapper can now be imported and used like any React component, while the underlying implementation is a web component with shadow DOM.

Form Integration​

Setting formAssociated: true in the CustomElement definition enables Form-Associated Custom Elements (FACE). This lets your component participate in HTML forms — it can submit values, integrate with validation, and respond to form resets.

export const MyFieldElement = CustomElement<MyFieldProps>({
reactComponent: MyField,
stylesheets: [Styles],
properties: {
name: String,
value: String,
},
formAssociated: true,
});

For details on form association, see the Proxies FACE guide.

SSR Support​

Custom elements created with @webbloqs/proxies support server-side rendering through Lit SSR. The CustomElement factory handles the SSR lifecycle automatically — no additional configuration is needed in the element definition.

For SSR setup in your application, see the @webbloqs/proxies documentation.

Exposed Elements (light DOM)​

exposed: true makes the element render into the light DOM instead of a shadow root:

export const MyFieldElement = CustomElement<MyFieldProps>({
reactComponent: MyField,
exposed: true,
});

Use it as a last resort. The known legitimate case is a form input wrapper for CMS-authored forms, where surrounding page CSS has to reach the inputs. It has two costs that are invisible until production:

The element can carry no stylesheets of its own. With no shadow root there is nothing to adopt them into, so a stylesheets option passed alongside exposed: true has no effect — silently, with no warning:

// ⚠️ Styles is ignored — there is no shadow root to adopt it into
CustomElement({ reactComponent: MyField, exposed: true, stylesheets: [Styles] });

Style these elements from the surrounding document instead, which is usually why light DOM was wanted in the first place.

Server and client disagree about the render root. An exposed element does server-render — its content is present in the SSR output. But Lit SSR has no light-DOM rendering path for LitElement, so it emits that content inside a <template shadowrootmode="open"> even here, while the client renders into the light DOM. Expect the server-rendered markup to differ structurally from what the client produces, and verify anything hydration-sensitive in a browser rather than from SSR output alone.

Import Conventions​

Always import from subpaths for tree-shaking:

// Correct
import { Button } from "@webbloqs/react/lib/button";
import { CustomElement } from "@webbloqs/proxies";

// ⚠️ Avoid — imports the entire library
import { Button } from "@webbloqs/react";

Why subpath imports matter: Barrel imports bypass tree-shaking, pulling in all components. Some components have side effects that can break other functionality — particularly in SSR contexts. Use subpath imports until tree-shaking is fully addressed in webbloqs.

File Organization​

A typical component folder in a consumer project:

src/components/my-component/
├── MyComponent.tsx # Layer 1: React component
├── MyComponentElement.tsx # Layer 2: CustomElement definition
├── react.tsx # Layer 3: React wrapper + registration
├── my-component.scss # Shadow DOM styles
└── index.ts # Public exports