Skip to content

React

Relements has no React wrapper, and it needs none. You write native HTML in JSX with .re-* classes and data-* attributes, opt into the enhance* behaviors and <re-*> custom elements when you want the optional JS layer, and bind native form controls with ordinary React state. The class/attribute/event contract is identical to every other framework — only the glue below is React-shaped.

Install the package, then import the stylesheet once at your app entry (e.g. main.jsx) so the re.* cascade layers load before anything renders:

Terminal window
npm install @relements/core
main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@relements/core/index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);

That is the entire zero-JS baseline. Write native elements with Relements classes in JSX — className carries the .re-* classes and React passes string attributes (including data-* and ARIA) straight through:

<button className="re-button" type="button">
Save
</button>

Everything that does not need interaction — buttons, cards, badges, fields — is done at this point. The sections below add the optional behavior layer.

A behavior is a function enhance*(root) that wires the markup under root and returns a controller { destroy() }. The idiomatic React primitive for “run on mount, clean up on unmount” is useEffect with a ref. Wrap that pairing in a tiny reusable hook:

useEnhance.js
import { useEffect, useRef } from "react";
// Runs enhanceFn(node) on mount and the returned controller.destroy() on cleanup.
export function useEnhance(enhanceFn) {
const ref = useRef(null);
useEffect(() => {
const controller = enhanceFn(ref.current);
return () => controller.destroy();
}, [enhanceFn]);
return ref;
}

Returning destroy() from the effect is not optional in React: StrictMode deliberately mounts, unmounts, and remounts every component once in development, so a behavior that does not tear down would double-wire its markup. The destroy() cleanup makes the remount a clean re-initialization (behaviors are also idempotent, so a stray re-run is a no-op — but the listeners still need removing).

Use it by spreading the returned ref onto the behavior’s host element:

import { enhanceTabs } from "@relements/core/behaviors/tabs";
import { useEnhance } from "./useEnhance.js";
function Tabs() {
const ref = useEnhance(enhanceTabs);
return (
<div className="re-tabs" data-re-tabs ref={ref}>
<div className="re-tabs__list" role="tablist" aria-label="Sections">
<button className="re-tab" role="tab" id="t1" aria-controls="p1" aria-selected="true">
One
</button>
<button
className="re-tab"
role="tab"
id="t2"
aria-controls="p2"
aria-selected="false"
tabIndex={-1}
>
Two
</button>
</div>
<section className="re-tabpanel" role="tabpanel" id="p1" aria-labelledby="t1" tabIndex={0}>
Panel one
</section>
<section
className="re-tabpanel"
role="tabpanel"
id="p2"
aria-labelledby="t2"
tabIndex={0}
hidden
>
Panel two
</section>
</div>
);
}

The same hook works for any of the behaviorsenhanceDialog, enhanceMenuButton, enhanceCombobox, and the rest — since they all share the enhance*(root) → { destroy() } contract. Pass a behavior that takes options by wrapping it: useEnhance((el) => enhanceCombobox(el)).

Behaviors and custom elements emit bubbling re-* CustomEvents. There is no onReChange prop — React’s synthetic event system only knows the standard DOM events — so listen the DOM way: a ref plus addEventListener inside useEffect, returning the matching removeEventListener for cleanup. Read the payload from event.detail:

import { useEffect, useRef, useState } from "react";
function TabsWithOutput() {
const ref = useRef(null);
const [lastTab, setLastTab] = useState("none");
useEffect(() => {
const el = ref.current;
const onChange = (event) => setLastTab(event.detail.tabId); // { tabId, panelId }
el.addEventListener("re-change", onChange);
return () => el.removeEventListener("re-change", onChange);
}, []);
return (
<>
<div className="re-tabs" data-re-tabs ref={ref}>
{/* …tablist + panels… */}
</div>
<p>
Last tab: <output>{lastTab}</output>
</p>
</>
);
}

Because re-* events bubble, you can also attach a single listener to a common ancestor instead of each host. The event name and detail shape are public API; see each component page for its payload (e.g. Tabs emits { tabId, panelId }, Menu button emits { item, value }).

The four <re-*> custom elements are a declarative alternative to calling a behavior yourself — drop the tag in and it enhances and cleans itself up via connectedCallback / disconnectedCallback. React 19 renders unknown tags like <re-tabs> as-is and passes string attributes through verbatim, so the light-DOM markup just works with no extra configuration. (On React 18 and earlier, unknown lowercase tags also render, but React forwards only string/number attributes — which is all these elements take — so the same markup is fine; the dedicated custom-element property and event support landed in React 19.)

Register each element you use with its bare side-effect import. The import runs customElements.define() for you; @relements/core lists its elements/*.js modules under sideEffects, so the registration survives bundler tree-shaking:

import "@relements/core/elements/re-tabs";
function CustomElementTabs() {
return (
<re-tabs aria-label="Sections">
<div className="re-tabs__list" role="tablist" aria-label="Sections">
<button className="re-tab" role="tab" id="a1" aria-controls="ap1" aria-selected="true">
Alpha
</button>
<button
className="re-tab"
role="tab"
id="a2"
aria-controls="ap2"
aria-selected="false"
tabIndex={-1}
>
Beta
</button>
</div>
<section className="re-tabpanel" role="tabpanel" id="ap1" aria-labelledby="a1" tabIndex={0}>
Alpha panel
</section>
<section
className="re-tabpanel"
role="tabpanel"
id="ap2"
aria-labelledby="a2"
tabIndex={0}
hidden
>
Beta panel
</section>
</re-tabs>
);
}

Custom elements emit the same re-* events as their behaviors, so listen for them exactly as in Events above — a ref on the <re-tabs> host and addEventListener("re-change", …) in useEffect. The element re-dispatches the behavior’s event, so event.detail.tabId reads the same.

Relements form controls are native elements with a .re-* class, so they bind to React state with no special handling — .re-input, .re-select, and the rest are just <input> / <select> you make controlled the ordinary way:

function NameField() {
const [name, setName] = useState("");
return (
<div className="re-field">
<label className="re-label" htmlFor="name">
Name
</label>
<input
className="re-input"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
);
}

Behavior-enhanced inputs commit through the same native events React already listens for: enhanceCombobox and enhanceNumberStepper dispatch native input/change, so your onChange fires normally. The only ones with a custom event are enhanceTagsInput (re-tags-change, { values }) — listen for that as in Events — and enhanceRange, whose two-thumb slider emits plain native input/change.

Try it now, no install — open it in a live editor:

Open in StackBlitz Edit in CodeSandbox

The full flow above — a .re-button, an enhanceTabs() region wired through useEnhance, and a <re-tabs> custom element whose re-change event drives an <output>, plus a “Toggle tabs” button that mounts/unmounts the subtree to exercise the destroy() teardown — lives in docs/examples/frameworks/react/, or on GitHub.