Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

React

@uniaria/react renders any UniAria definition in React with one hook. It has no component-specific code: every component on this site works through the same three exports.

import { OwnerProvider, useBehavior, useBehaviorState } from '@uniaria/react';

It needs React 18 or 19 (react is a peer dependency) and nothing else.

Internal state never renders your component again. Each part renders once with its current props, and after mount UniAria writes every change (hover, press, focus, an opening popover, the highlighted option) straight to the elements. Your component renders again only for props you pass and for state you select. See State.

useBehavior

useBehavior<D>(definition: D, props: PropsOf<D>): BehaviorHandle<D>

Creates an instance of definition for this component and returns props to spread onto each part.

import { toggleSwitch } from '@uniaria/core/switch';
import { useBehavior } from '@uniaria/react';

export function WifiSwitch() {
  const wifi = useBehavior(toggleSwitch, {
    defaultSelected: true,
    labelElementType: 'span',
    onSelectedChange: (isSelected) => console.log(isSelected),
  });
  return (
    <label {...wifi.parts.root} className="switch">
      <input {...wifi.parts.input} name="wifi" />
      <span {...wifi.parts.indicator} />
      <span {...wifi.parts.label}>Wi-Fi</span>
    </label>
  );
}

props are the component’s props as its page lists them, typed from its definition: values, default<Name> for uncontrolled values, on<Event> callbacks, an owner for components that have one, and link props such as tooltip. Attributes you own (id, name, aria-label) go in the JSX, next to the spread, and so does className: UniAria marks parts with attributes and never sets a class.

  • Every render stores the new props without notifying, and syncs them after commit in a layout effect, so watchers run once per change and React never sees an update during render. Callbacks may be inline: UniAria calls the latest ones.
  • One instance per component, created on the first render. A different definition creates a new one, so keep definitions at module scope.
  • Ids come from useId, so server and client agree. An id you write after a part’s spread wins, and UniAria points every reference at it once the part mounts (see Ids).

The handle

Member Description
instance The stable Instance. Pass it to useBehaviorState, to OwnerProvider, or as another component’s owner or link prop. instance.getState() reads state without subscribing.
actions The component’s public actions, such as an overlay’s open() and close(). Stable across renders.
parts Props for each part, from this render: parts.root, parts.input. Spread them onto the part’s element.
getItemProps(part, key) Props for one item part of one item, for collections: getItemProps('option', item.key). See item parts.

Render the parts you need: each component’s page says which parts are optional.

Actions are for commands from outside the component, such as opening a dialog when a save fails. Call them from handlers and effects, never during render. For a controlled component, an action only calls the change callback (onOpenChange), as a user’s click would.

const conflict = useBehavior(dialog, {});

async function save(draft: string) {
  const response = await fetch('/api/draft', { method: 'PUT', body: draft });
  if (response.status === 409) conflict.actions.open();
}

Part props in React

parts.<name> is a plain object of React props: attributes, event handlers, a style object when the part has one, and a ref. UniAria converts its part props to React’s names:

Part props React
class, for, tabindex, readonly, autocomplete, inputmode, maxlength, contenteditable, … className, htmlFor, tabIndex, readOnly, autoComplete, inputMode, maxLength, contentEditable, …
'' on a boolean attribute (disabled, hidden, checked, required, readonly, inert, …) true
aria-*, data-*, id, role, type Unchanged, as strings
focusin, focusout onFocus, onBlur (React’s bubble, like focusin and focusout)
input onInput
dblclick onDoubleClick
Other events: click, keydown, pointerdown, … onClick, onKeyDown, onPointerDown, …
style custom properties (--anchor-width) Kept as they are in style
Other style properties (inset-block-start) camelCase in style (insetBlockStart)
properties.value, properties.checked defaultValue, defaultChecked, from the first render only
  • Handlers are stable: each keeps its identity across renders and calls the handler from the latest state with event.nativeEvent. Definitions never see React’s synthetic events.
  • Part markers are attributes in the part props: data-uni on the root (data-uni="switch"), data-uni-part on a part outside it, and data-element on the other parts, which your CSS and Tasty select parts by. See Styling.
  • Your own handlers go next to the spread as usual. If you pass the same prop (onKeyDown) after the spread, it replaces UniAria’s; call both yourself if you need both.
  • Live values. An input’s value and a checkbox’s checked are never controlled by React. React renders the starting value once, as defaultValue or defaultChecked, so the server HTML shows it, and UniAria sets the DOM property after that. Control the value through the component’s props (value, isSelected), not through the element. Other live properties, such as a checkbox’s indeterminate, are set after mount.

Refs

Each part’s props include a ref, a stable callback that hands the element to UniAria. Keep it on the element: a ref written after the spread replaces it, and the part stops working. To get the element yourself, read instance.element(part) after mount, or wrap UniAria’s ref in a stable callback of your own:

const save = useBehavior(button, {});
const own = useRef<HTMLButtonElement | null>(null);
const ref = useCallback(
  (element: HTMLButtonElement | null) => {
    save.parts.root.ref(element);
    own.current = element;
  },
  [save.parts.root.ref],
);
return (
  <button {...save.parts.root} ref={ref}>
    Save
  </button>
);

Item parts

Collections (Listbox, Select, Combobox) repeat item parts per item. Spread getItemProps(part, key) onto each item’s element, with the item’s key as React’s key:

const ui = useBehavior(listbox, { items });
return (
  <ul {...ui.parts.list}>
    {items.map((item) => (
      <li key={item.key} {...ui.getItemProps('option', item.key)}>
        {item.textValue}
      </li>
    ))}
  </ul>
);

Item props are computed lazily, only for the items you render, and later changes reach each row’s element directly, so moving the highlight renders nothing. One handler per part and event serves every item.

useBehaviorState

useBehaviorState<D, T>(
  source: Instance<D> | BehaviorHandle<D>,
  selector: (state: StateOf<D>) => T,
  isEqual?: (a: T, b: T) => boolean,
): T

Reads one value of a component’s public state and renders again only when that value changes: Object.is by default, or isEqual. The state is what the component’s page lists under State.

import { dialog } from '@uniaria/core/dialog';

const confirm = useBehavior(dialog, {});
const isOpen = useBehaviorState(confirm, (state) => state.isOpen);
  • Select what the render needs, not the whole state: a selector that returns the state object renders on every change.
  • A selector returning a new object or array each time needs an isEqual, such as a shallow comparison.
  • The selector may be inline. A prop change that the selected value already shows doesn’t cause a second render.
  • It works in any component that has the instance or the handle, so a child can render from its parent’s state without rendering the parent.

Some components work inside another instance: a checkbox in its checkbox group, a radio in its radio group, a control in its field. A component finds its owner in this order:

  1. The owner prop, an instance: useBehavior(checkbox, { value: 'sms', owner: group.instance }). For parts that sit apart.
  2. OwnerProvider, the nearest one whose instance has the owner’s definition. It holds only the stable instance, so it renders nothing when state changes, and it crosses portals. The first render, and the server HTML, already have the owner’s state.
  3. The DOM, after mount: the nearest ancestor element that provides an owner, such as a <uni-checkbox-group> or a React group’s part.
import { checkbox } from '@uniaria/core/checkbox';
import { checkboxGroup } from '@uniaria/core/checkbox-group';
import { OwnerProvider, useBehavior } from '@uniaria/react';
import type { ReactNode } from 'react';

function Option(props: { value: string; children: ReactNode }) {
  const ui = useBehavior(checkbox, {
    value: props.value,
    labelElementType: 'span',
  });
  return (
    <label {...ui.parts.root}>
      <input {...ui.parts.input} />
      <span {...ui.parts.label}>{props.children}</span>
    </label>
  );
}

function Contact() {
  const group = useBehavior(checkboxGroup, { defaultValue: ['email'] });
  return (
    <div {...group.parts.root}>
      <span {...group.parts.label}>Contact me by</span>
      <OwnerProvider owner={group}>
        <Option value="email">Email</Option>
        <Option value="sms">SMS</Option>
      </OwnerProvider>
    </div>
  );
}

OwnerProvider takes owner, an instance or the handle useBehavior returned, and children. Providers nest; each component takes the nearest owner of the definition it needs.

Links go through a prop named after the link: a component that takes a tooltip gets the tooltip’s instance as tooltip:

const hint = useBehavior(tooltip, {});
const save = useBehavior(button, { tooltip: hint.instance });

React components also answer lookups from the DOM: a custom element inside a React checkbox group’s part finds that group. See Composition.

Attributes UniAria owns

UniAria owns every attribute a part’s props contain: ARIA state, the part markers (data-uni, data-uni-part, data-element), state attributes such as data-pressed, and the references it writes. The rest of the element, classes and a part’s id included, is yours: an id after the spread is kept, and the references follow it. An option’s id, made from its key, is UniAria’s.

In development, the first time each part element mounts, UniAria compares its attributes with the part props React just rendered. When you wrote one of its attributes yourself (a type, a data-pressed, an aria-expanded after the spread), it warns:

[uniaria] button: "type" on the root part is written by UniAria, but the element has "submit". Pass it as a prop, or drop it from the JSX.

Pass the value as the component’s prop instead (type: 'submit'), or remove it. Some components add warnings of their own, such as Button’s when the rendered tag doesn’t match elementType. Warnings are on while process.env.NODE_ENV isn’t 'production'.

StrictMode

UniAria works under StrictMode. Starting and stopping an instance is idempotent and can repeat, so the double mount attaches one set of listeners, and effects that restore focus or unlock scrolling don’t act on the simulated unmount.

Server rendering

useBehavior renders complete HTML on the server: ids, ARIA attributes, state attributes, the part markers, starting values of inputs and checkboxes, and state you select with useBehaviorState. Owners found through OwnerProvider are part of it. Effects never run on the server, so no listener, focus change or measurement happens there, and layout effects fall back to plain effects so React doesn’t warn. On the client, React hydrates the same markup, and UniAria attaches to the elements without a second render.

In React Server Components frameworks, useBehavior is a hook like any other: call it from a client component ('use client'). See Server rendering.

Types

Type Description
BehaviorHandle<D> What useBehavior returns.
ReactPartProps Props for one part: attributes, handlers, style and ref.
OwnerProviderProps<D> owner (an instance or a handle) and children.

Component props and state types come from each component’s subpath: ButtonProps and ButtonState from @uniaria/core/button.