Skip to content
HELiX

@helixui/react — React Wrappers

apps/docs/src/content/docs/framework-integration/react-wrappers Click to copy
Copied! apps/docs/src/content/docs/framework-integration/react-wrappers

@helixui/react provides auto-generated React wrapper components for every HELiX component. Each wrapper replaces ref-based addEventListener boilerplate with idiomatic React callback props (onHxClick, onHxChange, etc.) and ships full TypeScript types.

React’s synthetic event system does not forward custom DOM events. Without wrappers, listening to an hx-click event requires this:

const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.addEventListener('hx-click', handleClick);
return () => el.removeEventListener('hx-click', handleClick);
}, [handleClick]);
return <hx-button ref={ref}>Save</hx-button>;

With @helixui/react, that becomes:

import { HxButton } from '@helixui/react';
return <HxButton onHxClick={handleClick}>Save</HxButton>;

The wrapper also provides:

  • Typed props and events — each wrapper exposes the underlying CEM properties as typed React props plus typed onHx* callbacks (no manual helix.d.ts for the attribute side)
  • Slot composition — child elements with slot="<name>" are forwarded to the host; the slot names themselves are not encoded in the React prop type
  • Automatic ref forwardingref gives you the underlying HTMLElement
  • React DevTools labels — component tree shows <HxButton> instead of <hx-button>
Terminal window
npm install @helixui/react

@helixui/react has a peer dependency on react and react-dom. No additional setup is required — the package registers web components internally.

Import only what you need. Each component is tree-shakeable:

import { HxButton } from '@helixui/react';
import { HxTextInput } from '@helixui/react';
import { HxCard, HxDialog } from '@helixui/react';

Import everything from the package root. Convenient for prototyping; prefer named imports in production for smaller bundles:

import * as Helix from '@helixui/react';
// <Helix.HxButton>, <Helix.HxTextInput>, ...

Every wrapper uses the PascalCase version of the tag name:

Tag nameReact wrapper
hx-buttonHxButton
hx-text-inputHxTextInput
hx-cardHxCard
hx-dialogHxDialog
hx-badgeHxBadge

Every hx-* event exposed by a component is available as a typed on* prop. The event name is converted from kebab-case to camelCase with an on prefix:

DOM eventReact propEmitting components
hx-clickonHxClickhx-button, hx-card (when hx-href), etc.
hx-inputonHxInputhx-text-input, hx-textarea
hx-changeonHxChangehx-text-input, hx-select, hx-checkbox, etc.
hx-openonHxOpenhx-dialog, hx-accordion-item, etc.
hx-closeonHxClosehx-dialog, hx-alert (after-close), etc.

The wrapper exposes every event the underlying component declares in its CEM. Components do not emit hx-focus / hx-blur events — use native React onFocus / onBlur (or DOM focusin / focusout) for focus handling on hx-* elements.

Generated onHx* props are typed as (event: Event) => void to match @lit-labs/react’s event-prop signature. Narrow the event inside the handler with a CustomEvent cast and your component’s detail shape (the detail payload is documented in each component’s CEM entry):

import { HxTextInput } from '@helixui/react';
function SearchField() {
const handleInput = (event: Event) => {
const { value } = (event as CustomEvent<{ value: string }>).detail;
console.log(value);
};
return (
<HxTextInput
label="Search"
placeholder="Type to search…"
onHxInput={handleInput}
/>
);
}
import { HxButton } from '@helixui/react';
function SaveButton({ onSave }: { onSave: () => void }) {
return (
<HxButton variant="primary" onHxClick={onSave}>
Save
</HxButton>
);
}

Pass a ref to get the underlying HTMLElement. Use this to call imperative methods or read DOM properties directly:

import { useRef } from 'react';
import { HxButton, HxDialog } from '@helixui/react';
function ConfirmDialog() {
// hx-dialog exposes show() / showModal() / close() — there is no hide() method.
const dialogRef = useRef<HTMLElement & {
show: () => void;
showModal: () => void;
close: () => void;
}>(null);
const open = () => dialogRef.current?.showModal();
const close = () => dialogRef.current?.close();
return (
<>
<HxButton onHxClick={open}>Open dialog</HxButton>
<HxDialog ref={dialogRef} heading="Confirm action" modal onHxClose={close}>
<p>Are you sure?</p>
<HxButton slot="footer" variant="primary" onHxClick={close}>Confirm</HxButton>
<HxButton slot="footer" variant="ghost" onHxClick={close}>Cancel</HxButton>
</HxDialog>
</>
);
}

Pass slot content using the slot attribute on child elements, exactly as you would with raw web components:

import { HxButton, HxCard } from '@helixui/react';
function PatientCard({ name, id }: { name: string; id: string }) {
return (
<HxCard>
<h3 slot="heading">{name}</h3>
<p>Patient ID: {id}</p>
<HxButton slot="footer" variant="ghost" hx-size="sm">
View record
</HxButton>
</HxCard>
);
}

To confirm only the components you import are included in your bundle, inspect with your bundler’s analysis tool:

Terminal window
# Next.js bundle analyzer
npm install --save-dev @next/bundle-analyzer
# next.config.ts
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: true });
export default withBundleAnalyzer({});
# Run
ANALYZE=true npm run build

In the bundle map, you should see individual component chunks (e.g., hx-button.js, hx-text-input.js) rather than a single monolithic @helixui chunk.

Per-component sizes vary — small atoms sit under 5 KB min+gz; richer components (combobox, dialog, dropdowns) carry budgeted overages defined in bundle-budgets.json at the repo root. Run node scripts/measure-component-size.js (in the HELiX monorepo) or pnpm run check:bundle to see current numbers.

Custom elements register via customElements.define, which is a browser-only API. Any file importing from @helixui/react must be a Client Component:

'use client'; // Required — always at the top of files importing @helixui/react
import { HxButton } from '@helixui/react';
export function ActionButton({ label, onClick }: { label: string; onClick: () => void }) {
return <HxButton variant="primary" onHxClick={onClick}>{label}</HxButton>;
}

For apps that use HELiX components on many pages, register all components once in the root layout:

components/helix-provider.tsx
'use client';
import { useEffect } from 'react';
export function HelixProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Registration happens automatically when @helixui/react is imported.
// This component exists to ensure it loads on the client.
import('@helixui/react');
}, []);
return <>{children}</>;
}
// app/layout.tsx (Server Component — no 'use client' needed)
import { HelixProvider } from '@/components/helix-provider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<HelixProvider>{children}</HelixProvider>
</body>
</html>
);
}

For interactive islands, wrap only the interactive parts in Client Components:

components/patient-search.tsx
'use client';
import { useState } from 'react';
import { HxTextInput, HxButton } from '@helixui/react';
interface PatientSearchProps {
onSearch: (query: string) => void;
}
export function PatientSearch({ onSearch }: PatientSearchProps) {
const [query, setQuery] = useState('');
return (
<div className="search-bar">
<HxTextInput
label="Search patients"
placeholder="Name or patient ID"
value={query}
onHxInput={(e) => setQuery((e as CustomEvent<{ value: string }>).detail.value)}
/>
<HxButton
variant="primary"
disabled={!query}
onHxClick={() => onSearch(query)}
>
Search
</HxButton>
</div>
);
}
// app/patients/page.tsx (Server Component)
import { PatientSearch } from '@/components/patient-search';
export default function PatientsPage() {
return (
<main>
<h1>Patient Records</h1>
<PatientSearch onSearch={async (query) => { 'use server'; /* ... */ }} />
</main>
);
}

A minimal patient portal UI demonstrating common wrapper patterns:

// app/page.tsx (Server Component)
import { PatientPortal } from '@/components/patient-portal';
export default function HomePage() {
return <PatientPortal />;
}
components/patient-portal.tsx
'use client';
import { useState } from 'react';
import {
HxButton,
HxTextInput,
HxCard,
HxBadge,
HxDialog,
} from '@helixui/react';
interface Patient {
id: string;
name: string;
status: 'active' | 'inactive';
}
const MOCK_PATIENTS: Patient[] = [
{ id: 'P-001', name: 'Jane Smith', status: 'active' },
{ id: 'P-002', name: 'Robert Chen', status: 'inactive' },
{ id: 'P-003', name: 'Maria Garcia', status: 'active' },
];
export function PatientPortal() {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Patient | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const filtered = MOCK_PATIENTS.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.id.includes(query)
);
const selectPatient = (patient: Patient) => {
setSelected(patient);
setDialogOpen(true);
};
return (
<div className="portal">
<h1>Patient Portal</h1>
<HxTextInput
label="Search patients"
placeholder="Name or patient ID"
onHxInput={(e) =>
setQuery((e as CustomEvent<{ value: string }>).detail.value)
}
/>
<div className="patient-list">
{filtered.map((patient) => (
<HxCard key={patient.id}>
<h3 slot="heading">{patient.name}</h3>
<p>ID: {patient.id}</p>
<HxBadge variant={patient.status === 'active' ? 'success' : 'neutral'}>
{patient.status}
</HxBadge>
<HxButton
slot="footer"
variant="ghost"
hx-size="sm"
onHxClick={() => selectPatient(patient)}
>
View record
</HxButton>
</HxCard>
))}
</div>
{selected && (
<HxDialog
open={dialogOpen}
heading={`Record: ${selected.name}`}
onHxClose={() => setDialogOpen(false)}
>
<dl>
<dt>Patient ID</dt>
<dd>{selected.id}</dd>
<dt>Status</dt>
<dd>{selected.status}</dd>
</dl>
<HxButton
slot="footer"
variant="primary"
onHxClick={() => setDialogOpen(false)}
>
Close
</HxButton>
</HxDialog>
)}
</div>
);
}