Components
Button
Section titled “Button”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>;| element | what 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.
Switch
Section titled “Switch”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 flipsSwitch.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.
Why two parts
Section titled “Why two parts”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.
Form submission
Section titled “Form submission”Give Switch.Root a name and it submits with the enclosing form, mirroring a native checkbox —
an unchecked switch contributes nothing.
Checkbox
Section titled “Checkbox”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.
Three states, one prop
Section titled “Three states, one prop”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-checkedWhy two parts
Section titled “Why two parts”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.
RadioGroup
Section titled “RadioGroup”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.
Why two parts, and no indicator
Section titled “Why two parts, and no indicator”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.
Where the value lives
Section titled “Where the value lives”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.
Dialog
Section titled “Dialog”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.
State drives the element
Section titled “State drives the element”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 throughonOpenChange(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. Thecloseevent 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.
Why five parts
Section titled “Why five parts”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.
Popover
Section titled “Popover”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.
Combobox
Section titled “Combobox”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.
Tooltip
Section titled “Tooltip”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.