Patterns

Patterns — Base UI

Composing with render

When to use: swapping a part's rendered element (e.g. Menu.Item as an <a>), or composing Base UI's behavior onto your own design-system components. How: pass render={<MyComponent />} (element form) or render={(props, state) => <el {...props}>...</el>} (function form, for state-driven output). Your component must spread all props onto its DOM node and forward its ref, or Base UI's injected behavior breaks silently. Trade-offs: full control over markup, but you inherit responsibility for keeping the swapped element accessible and functional; the function form needs mergeProps if you add your own props/handlers.

Styling via data-* attributes

When to use: any stateful component (Checkbox, Accordion, Menu, Dialog, etc.). How: target [data-open], [data-checked], [data-highlighted], etc. in CSS/CSS-in-JS instead of tracking state in JS and toggling classes manually. Trade-offs: none — this is the idiomatic, zero-JS-overhead approach the whole library is designed around.

Animating with CSS transitions

When to use: default choice for any open/close animation. How: style [data-starting-style]/[data-ending-style] with a transition; preferred over @keyframes because it can be smoothly cancelled mid-animation (e.g. closing a popup that's still opening). Trade-offs: none for simple fade/scale transitions; use CSS keyframes ([data-open]/[data-closed]) only for effects a transition can't express.

Animating with a JS library (Motion)

When to use: spring physics, layout animation, or gesture-driven exits CSS can't do. How: pick the pattern by the component's default mount behavior — unmounted-by-default popups use AnimatePresence + Portal keepMounted + render={<motion.div exit={...}/>}; always-keepMounted popups animate off state.open inside the render function with no AnimatePresence; hybrid components (Select) need both, switched on a first-mount flag. Trade-offs: more setup than CSS; must animate opacity even minimally (e.g. to 0.9999) if the real animation doesn't touch it, so Base UI's getAnimations() completion detection still fires.

Detached triggers via Handle

When to use: a trigger can't be a sibling of its target Root (e.g. a Dialog trigger living inside a Menu.Item). How: const handle = Component.createHandle(); pass handle to both the Root and any external Triggers. Call handle.open()/.close()/.openWithPayload() only from event handlers/effects, never during render. Trade-offs: slightly more setup than lifting open/onOpenChange state, but avoids prop-drilling across unrelated tree branches.

Positioned overlay content

When to use: Menu, Popover, Select, Combobox, Autocomplete, Tooltip, ContextMenu, NavigationMenu, PreviewCard, Toast. How: configure side/align/sideOffset/alignOffset plus collisionBoundary/collisionPadding/collisionAvoidance/sticky on Positioner; use the exposed --anchor-width/-height, --available-width/-height, --transform-origin CSS variables for origin-aware animation and size constraints. Trade-offs: shared API across many components means the technique transfers directly once learned once — except Select's alignItemWithTrigger mode, which ignores side/align while active.

Roving tabindex collections

When to use: Menu family (Item/CheckboxItem/RadioItem/SubmenuTrigger), Radio Group, Tabs, Toggle Group, Toolbar, Menubar. How: rely on the built-in single-Tab-stop-plus-arrow-keys behavior; configure loopFocus/orientation rather than implementing focus movement yourself. Trade-offs: none if you stay within the component's own item parts — reimplementing this by hand is exactly the hard part these components exist to solve.

Form-compatible stateful controls

When to use: Checkbox, RadioGroup, Switch, Slider, Autocomplete, Combobox, NumberField, OTPField inside a native <form>. How: rely on the automatically-rendered hidden native input; wrap in Field.Root name="..." for value registration, description/error association, and validation timing (validationMode). Trade-offs: default path needs zero extra code for form participation; only reach for lower-level control (e.g. uncheckedValue) when the native submission default doesn't match product needs.

Server + client validation on one field

When to use: any field needing both instant feedback and a post-submit server check (e.g. username/promo-code availability). How: Field.Root validate/validationMode/validationDebounceTime for client-side; pass server results to <Form errors={{fieldName: msg}}>, which merges into the same Field.Error display and auto-clears when the field's value next changes. Trade-offs: none — both error sources render through one path, no separate UI branch needed.

RTL support

When to use: any app needing right-to-left layout/keyboard behavior. How: wrap the app once in <DirectionProvider direction="rtl"> — but still set dir="rtl"/direction: rtl yourself, since the provider only changes component behavior, not HTML/CSS. Use useDirection() inside portaled custom content that needs to know the ambient direction. Trade-offs: two things to remember (provider + actual dir attribute) instead of one; forgetting the dir attribute leaves visuals LTR while interaction logic is RTL.

Building a custom render-accepting component

When to use: an in-house component meant to compose the same way Base UI's own parts do. How: useRender({ defaultTagName, render, state, props: mergeProps(defaultProps, otherProps) }); type external props with useRender.ComponentProps<Tag>, internal defaults with useRender.ElementProps<Tag>; pass ref: internalRef (React 19) or ref: [forwardedRef, internalRef] inside forwardRef (React 18/17). Trade-offs: ~10 lines of boilerplate per component, but yields full render/data-*-state parity with the rest of the library.