Skip to content

Components

Button renders a native <button> and expresses disabled the way it already understands. The element is natively focusable and keyboard-activatable, so nothing is synthesised on top of it.

import { Button } from '@arun-dev/headless/button';
<Button onClick={save}>Save</Button>;
elementwhat it adds
default<button>type="button", so it never submits
disabled<button disabled>the platform’s own disabled semantics
pending<button disabled>disabled while it lasts, data-pending

Button does not navigate — a link is a separate Link component. Use render for a component that wraps a button — an animation wrapper, a tooltip trigger. It is merged into, not replaced.

An immediate on/off control, made of two parts.

import { Switch } from '@arun-dev/headless/switch';
<Switch.Root className="my-switch" onCheckedChange={save} aria-label="Notifications">
<Switch.Thumb className="my-switch-thumb" />
</Switch.Root>;

Switch.Root is always a native <button>, carrying role="switch" and aria-checked — focus, Space, Enter and disabled all come from the platform, which is why the component is short. It has no accessible name of its own — pair it with a <label htmlFor> by id, or pass aria-label. A headless component should not guess at your copy.

sequenceDiagram
  actor User
  participant Root as Switch.Root
  participant Ctx as SwitchRootContext
  participant Thumb as Switch.Thumb

  Root->>Ctx: provide { checked, disabled }
  Ctx->>Thumb: read state
  Note over Root,Thumb: both project the same data-* attributes

  User->>Root: click
  Root->>Root: useControlled setter
  Root-->>User: onCheckedChange(next)
  Root->>Ctx: new state
  Ctx->>Thumb: re-render, data-checked flips
The thumb reads state through context rather than props, so the two parts cannot get out of step.

Switch.Thumb takes no state props at all. That is what makes <Switch checked thumbChecked={false} /> impossible to write. Both parts emit the same data-checked / data-unchecked / data-disabled, so you can style either one.

The thumb could be a pseudo-element — but then it could not take a ref, could not accept a render prop, and could not hold children. That last one matters: a thumb often contains an icon or a loading spinner — worked example.

The cost is that nothing enforces that a thumb exists. <Switch.Root /> on its own renders a valid, empty track.

Give Switch.Root a name and it submits with the enclosing form, mirroring a native checkbox — an unchecked switch contributes nothing.

A three-state control read on submit, made of two parts.

import { Checkbox } from '@arun-dev/headless/checkbox';
<Checkbox.Root className="my-checkbox" name="terms" aria-label="Accept terms">
<Checkbox.Indicator className="my-checkbox-mark" />
</Checkbox.Root>;

Checkbox.Root is a native <button> carrying role="checkbox" and aria-checked, for the same reason Switch.Root is: an <input type="checkbox"> is void and cannot hold an indicator, so it fails the content test above. The hidden input it renders for a name keeps everything the native element was still good for.

checked is boolean | 'indeterminate', not a boolean beside an indeterminate flag. One prop makes checked && indeterminate unrepresentable, and leaves a single value to derive the ARIA, the data-* attributes and the submitted value from.

Native HTML splits the two, but only because .indeterminate is a DOM property with no attribute behind it — React cannot set it declaratively, the first click clears it behind React’s back, and form.reset() leaves it alone. There was nothing there to inherit.

A click never moves to indeterminate: activating a mixed checkbox resolves it to checked, as a native one does. Only a parent can set the third state.

sequenceDiagram
  actor User
  participant Root as Checkbox.Root
  participant Ctx as CheckboxRootContext
  participant Ind as Checkbox.Indicator

  Root->>Ctx: provide { checked, disabled }
  Ctx->>Ind: read state
  Note over Root,Ind: checked is true, false or indeterminate —<br/>one of three data-* attributes, never two

  User->>Root: click
  Root->>Root: commitChecked(next)
  Root-->>User: onCheckedChange(next)
  Root->>Ctx: new state
  Ctx->>Ind: re-render, data-indeterminate gives way to data-checked
A click resolves the third state rather than cycling through it, and the indicator follows from context.

The same reason as Switch: an indicator that is a real element can take a ref, accept a render prop and hold children — which is how a consumer swaps in their own check, dash or spinner. It reads state from the Root through context, so the two cannot get out of step.

Checkbox.Indicator renders in every state, unchecked included, so CSS has an element to transition and a place to put an unchecked mark. It ships no glyph of its own — like every part here it has no colour, no class and no content until you give it one. @arun-dev/ui is the layer that supplies a default check and dash.

Both parts emit data-checked / data-unchecked / data-indeterminate / data-disabled. Three mutually exclusive attributes rather than one with a value, so all three are addressable at equal specificity — and so the third state could be added without :not([data-checked]) silently absorbing it, which is what data-unchecked was introduced for.

One choice from a set, made of two parts: the group, which owns the value, and the radios.

import { RadioGroup } from '@arun-dev/headless/radio-group';
<RadioGroup.Root className="my-group" name="plan" aria-label="Plan">
<label>
<RadioGroup.Item className="my-radio" value="free" />
Free
</label>
</RadioGroup.Root>;

RadioGroup.Item is a native <input type="radio">, the first value-carrying control here that is. It passes all three tests where Checkbox and Switch fail the content one: a radio’s dot is a ::before, so nothing needs to go inside the input. What staying native keeps is exactly what a <button role="radio"> would have to rebuild: arrow-key selection, a single Tab stop at the checked radio, required validation, and the form protocol.

RadioGroup.Root is a separate element the consumer places, and its radios need something from it: the shared name, the value, disabled, required and form. So it earns a part. An indicator would not. The input is void, so there is nothing for one to wrap, and decision 6 gives one element one component.

The group owns a single value through useControlled, and each radio derives checked from it, so React restores any change the group declines. A name is generated when none is given, because the platform only groups radios that share one.

form.reset() resets the inputs natively, and React’s value would not follow. The group listens for the reset, puts the inputs back to match its value, then commits the mount-time value through the same setter a change goes through. This is the seam useFormReset closes for Checkbox and Switch, closed at the group because a reset there concerns several inputs at once.

Radios emit data-checked / data-unchecked / data-disabled, and the group data-disabled. The shape matches the other controls, so a stylesheet reads them all the same way.

import { Dialog } from '@arun-dev/headless/dialog';
<Dialog.Root onOpenChange={track}>
<Dialog.Trigger className="my-button">Delete…</Dialog.Trigger>
<Dialog.Popup className="my-dialog">
<Dialog.Title>Delete report.pdf?</Dialog.Title>
<Dialog.Close className="my-button">Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>;

Dialog.Popup is a native <dialog> opened with showModal(), so the top layer, the backdrop, the inert page, the focus trap and focus return are the platform’s. None of it is rebuilt here. What the component adds is state: one open value owned by Dialog.Root, and a single setter that every close path goes through.

After each render the popup compares its open state with the element’s and calls showModal() or close() to match. The platform’s own ways of closing are turned into requests instead, so a controlled parent can refuse any of them:

  • Esc fires cancel. It is prevented, then reported through onOpenChange(false).
  • A backdrop click is a click on the <dialog> itself, outside its box. The press must start there too, so a text selection dragged out of the dialog is not one.
  • <form method="dialog"> closes the element before anyone is asked. The close event reports it, and if the state stays open the element is reopened.

A cancel or close from a nested dialog reaches the outer popup’s handlers through React’s tree, though not the DOM’s, so each popup acts only on events whose target is its own element.

Each one is an element the consumer places that needs something from the Root (decision 6). Trigger and Close need the setter, Popup the state and the title’s id, and Title supplies that id for aria-labelledby. There is no Portal, because the top layer needs none, and no Backdrop, because ::backdrop is not an element anyone places.

Both Trigger and Popup emit data-open / data-closed.

import { Popover } from '@arun-dev/headless/popover';
<Popover.Root>
<Popover.Trigger className="my-button">Filters</Popover.Trigger>
<Popover.Popup className="my-popover" side="bottom" align="start" aria-label="Filters">
…
</Popover.Popup>
</Popover.Root>;

Popover.Popup is a native popover="auto", shown with showPopover({ source }) so the Trigger becomes its invoker: Tab moves from the Trigger into it, and focus returns on close. Light dismiss, Esc and one-open-at-a-time are the platform’s.

Placement is CSS anchor positioning, set inline (decision 7). The Trigger carries a generated anchor-name (--hl-anchor-<id>), and the Popup gets position-anchor naming it, position-area from side and align, and position-try-fallbacks to flip across the axis it opens along. Nothing is measured, so nothing needs updating on scroll or resize. Your own style merges with these rather than replacing them.

Light dismiss and Esc close the element before the component is asked — beforetoggle can cancel an open, not a close — so the toggle event reports it, and the popup reopens if a controlled parent keeps it open. It is Dialog’s <form method="dialog"> path again.

Both Trigger and Popup emit data-open / data-closed; the Popup also data-side and data-align, repeating the request rather than where a fallback moved it.

import { Menu } from '@arun-dev/headless/menu';
<Menu.Root>
<Menu.Trigger className="my-button">Actions</Menu.Trigger>
<Menu.Popup className="my-menu">
<Menu.Item className="my-item" onClick={edit}>
Edit
</Menu.Item>
<Menu.Item className="my-item" disabled>
Archive
</Menu.Item>
</Menu.Popup>
</Menu.Root>;

A menu button, by the WAI-ARIA pattern. Menu.Popup is a native popover="auto" with role="menu", labelled by the Trigger and anchored to it exactly as Popover is (decision 7), so light dismiss, Esc, one-open-at-a-time and focus return are the platform’s.

Focus moves into the menu. A click, Enter, Space or Down on the Trigger opens it at the first item; Up opens it at the last. Items are native <button role="menuitem">s with tabIndex={-1}: Up and Down move between them, wrapping, with Home and End, through the same core/rovingFocus as Tabs. Tab closes the menu and lets focus move on from the Trigger.

An Item is activated as a button. Put the action in onClick; the component then closes the menu. Call event.preventComponentHandler() to keep it open.

Disabled items are skipped by default. With focusableWhenDisabled on the Root they stay in the arrow-key sequence, as aria-disabled rather than disabled, and the component blocks their activation — your onClick is not attached while an item is disabled.

Trigger and Popup emit data-open / data-closed; the Popup also data-side and data-align, and a disabled Item data-disabled.

import { Combobox } from '@arun-dev/headless/combobox';
<Combobox.Root items={products} itemToKey={(p) => p.id} multiple name="products">
<Combobox.InputGroup className="my-field">
<Combobox.Value>
{(value, { itemToString }) =>
value.map((item) => (
<Combobox.Chip key={item.id} value={item} className="my-chip">
{itemToString(item)}
<Combobox.ChipRemove aria-label={`Remove ${itemToString(item)}`} />
</Combobox.Chip>
))
}
</Combobox.Value>
<Combobox.Input aria-label="Products" />
<Combobox.Clear aria-label="Clear" />
<Combobox.Trigger aria-label="Show products" />
</Combobox.InputGroup>
<Combobox.Popup className="my-list">
<Combobox.Empty>No products</Combobox.Empty>
<Combobox.List>
{(item) => (
<Combobox.Item key={item.id} value={item}>
{item.label}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>;

A text input that filters a list, by the WAI-ARIA combobox pattern, with Base UI’s behaviour (decision 10). Items are data: the Root takes items and filters them with filter, and List renders what is left through a function. value holds the items themselves, so a selection survives a search that no longer returns it.

Focus stays in the input. The highlighted option is its aria-activedescendant, moved by Up and Down. The highlight lives outside React state, so moving it re-renders only the two items it moves between. Trigger, Clear and ChipRemove are out of the tab order, and a press on them keeps focus in the input.

The popup is popover="manual", anchored to the InputGroup if there is one, else to the Input. auto’s light dismiss would close the list on a press of the input itself, so the component closes it on a press outside its own elements, and on Esc and blur.

Parts the consumer places: InputGroup (optional) takes over as the anchor; Value renders the selection, for chips; Chip and ChipRemove remove an item, and the arrow keys reach chips from the start of the input. Empty shows when nothing matches and nothing is loading; Status is a polite live region. required on the Root makes the input required while nothing is selected. When items are groups (objects with an items array), List’s function gets one group at a time, and renders a Group (role="group", disabled like <optgroup disabled>) with a GroupLabel and its Items. Separator draws a line between them, aria-hidden, since a listbox may hold only options and groups.

Every change callback receives { reason }. The Input, Trigger, Popup and InputGroup emit data-open / data-closed; an Item data-selected, data-highlighted and data-disabled; Clear data-visible while there is a selection to clear.

import { Tooltip } from '@arun-dev/headless/tooltip';
<Tooltip.Root delay={600}>
<Tooltip.Trigger className="my-icon-button" aria-label="Bold">
B
</Tooltip.Trigger>
<Tooltip.Popup className="my-tooltip">Bold (⌘B)</Tooltip.Popup>
</Tooltip.Root>;

Tooltip.Popup is a native popover="manual" with role="tooltip", anchored exactly as Popover is (decision 7). popover="hint" is the platform’s tooltip mode, but Safari lacks it; manual behaves the same everywhere and, like hint, does not close an open Popover when it shows.

What manual leaves to the component is timing: a mouse or pen opens it after delay, leaving closes it after closeDelay unless the pointer moves onto the tooltip, keyboard focus opens it at once (only :focus-visible, so not after a click), blur and a press close it, and touch never opens it. Esc is taken in the capture phase and stopped, so it closes the tooltip and not a Dialog or Popover underneath. Moving from one tooltip to the next skips the delay — a module-level count of open tooltips, so no Provider is needed.

The Trigger’s aria-describedby points at the popup at all times; the description is computed from the hidden element too. The popup takes no source, so it never joins the Trigger’s Tab order — a tooltip is never focused.

import { Tabs } from '@arun-dev/headless/tabs';
<Tabs.Root defaultValue="account" activationMode="automatic">
<Tabs.List className="my-tablist" aria-label="Settings">
<Tabs.Tab className="my-tab" value="account">
Account
</Tabs.Tab>
<Tabs.Tab className="my-tab" value="billing">
Billing
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="account">…</Tabs.Panel>
<Tabs.Panel value="billing">…</Tabs.Panel>
</Tabs.Root>;

There is no native tabs element, so this is the first composite widget built here: a tablist of native <button role="tab">s, each paired with a role="tabpanel" by a shared value.

Roving tabindex. Only the tab holding the group’s Tab stop has tabIndex={0} — the selected one, or the first enabled tab when that is disabled or missing — so Tab enters the list once and leaves it on the next press. The List moves focus with the arrows along orientation, wrapping, plus Home and End, skipping disabled tabs and swapping Left and Right in a right-to-left layout. The key logic is an internal core/rovingFocus module, shared with Menu.

Disabled tabs are skipped by default. With focusableWhenDisabled on the Root and activationMode="manual", they stay in the arrow-key sequence: a disabled Tab is then aria-disabled rather than disabled, and the component blocks its selection and your onClick. Under "automatic" the prop has no effect, since focusing a tab selects it.

Tabs register from their props. Each Tab tells the Root its value and disabled when it mounts, and the Root orders them by document position; nothing is read back from the DOM (decision 4). Elements are kept only to move focus to, so tabs can be added, removed or reordered.

Activation belongs to the Tab. With activationMode="automatic", a Tab selects itself when focused; with "manual", Enter, Space or a click do — the button’s own activation.

Tabs and Panels emit data-selected / data-unselected, and a disabled Tab data-disabled; Root and List emit data-orientation.