Base UI Primitives
Quick Guide: Base UI is an unstyled React primitive library. Components are assembled from named parts (
Root,Trigger,Portal,Positioner,Popup,Arrow), polymorphism goes through therenderprop rather thanasChild, state is published as data attributes and as an argument toclassName/stylefunctions, and every change handler receives aneventDetailswhosecancel()vetoes the change. Current: v1.7.0 — package@base-ui/react, imported per component subpath.
Detailed Resources:
- examples/core.md — part anatomy, portal containers, menus, positioning and collision handling
- examples/composition.md — both
renderforms, ref merging,useRender,mergePropsprecedence - examples/styling.md — attribute selectors, state functions, CSS variables, all four animation routes
- examples/state.md — controlled vs uncontrolled,
eventDetailscancelation,actionsRef - examples/forms.md — Field anatomy, validation modes, server errors, Fieldset, external runtimes
- reference.md — data-attribute and prop tables, checklists, Radix migration notes, version history
Before writing Base UI code
Install @base-ui/react and import from per-component subpaths — @base-ui/react/popover, @base-ui/react/field. @base-ui-components/react stopped at a release candidate and its APIs differ.
Reach for the render prop for polymorphism. There is no asChild here. A component passed to render forwards its ref and spreads every received prop onto its DOM node, which is what carries the event handlers and ARIA wiring.
Nest popups as Portal > Positioner > Popup, with every positioning prop on Positioner. side, align, sideOffset and collisionPadding belong to the element that carries the placement transform; Popup stays free for your own styling and transforms.
Style state from the data attributes — data-open/data-closed, data-starting-style/data-ending-style, data-popup-open — or from the state argument to className/style. The attributes are already correct and cost no render.
Call eventDetails.cancel() to veto a change. The handler's return value is ignored, so an early return leaves an uncontrolled component updating anyway.
Auto-detection: @base-ui/react, useRender, mergeProps, mergePropsN, Positioner, Popup, Backdrop, Viewport, data-popup-open, data-starting-style, data-ending-style, data-uncentered, eventDetails, preventBaseUIHandler, Field.Root, Fieldset.Legend, alignItemWithTrigger, keepMounted, actionsRef, onOpenChangeComplete, DirectionProvider, CSPProvider
Applies to:
- Building an accessible design system where you own the whole visual layer
- Popup positioning with collision handling as a separately styleable part
- Vetoing or inspecting the reason behind a state change —
eventDetails.reason,eventDetails.cancel() - Native-form-compatible field grouping and validation without a form runtime
- Writing your own primitives that behave like the library's —
useRender,mergeProps
Handled elsewhere:
- Pre-styled components — this library ships zero CSS by design; a kit that arrives with its own appearance answers a different question.
- How the CSS itself is authored — parts accept
classNameandstyleand publish state as attributes; which styling layer consumes them settles nothing here. - Form state and schema validation —
Fieldhandles presentation, ARIA and native constraint validation; a runtime that owns values across a wizard plugs in throughvalue/onValueChange/invalid. - Animation runtimes —
keepMountedandactionsRef.unmount()are the two handoff points; what drives the motion beyond CSS is not settled here. - Non-React usage — these primitives are React-only.
Philosophy
Base UI supplies behaviour, accessibility and positioning, and no appearance at all. Three consequences follow.
Parts are separate elements, not props. A popup is Root, Portal, Positioner, Popup and Arrow, plus an optional Backdrop and Viewport, each independently styleable and replaceable. That is why positioning props live on Positioner: the positioned element is a different element from the decorated one, so your transforms never fight the positioning math.
State is published, not hidden. Every part writes its state to data attributes and passes the same state object to className and style when those are functions. Reading it from the DOM in CSS, or from the callback argument in JS, is cheaper and more correct than duplicating it into React state.
Composition is a prop, not a wrapper. Instead of a Slot element and an asChild boolean, every part takes render — an element to clone, or a function of (props, state) returning an element. useRender exposes the same mechanism so components you write behave identically to components the library ships.
Uncontrolled first, with an escape hatch. Components manage their own state by default. Controlling one means supplying value/open plus a handler — but the handler also receives eventDetails, so you can inspect why a change was requested and refuse it without ever taking ownership.
Decision Framework
Which popup component?
Does it block the rest of the page until answered?
├─ YES → destructive or irreversible confirmation?
│ ├─ YES → Alert Dialog
│ └─ NO → Dialog (Drawer for an edge-anchored panel)
└─ NO → what opens it?
├─ Hover or focus, short text → Tooltip
├─ Hover or focus, rich content → Preview Card
├─ Right-click → Context Menu
├─ Click, list of commands → Menu (Menubar when in a bar)
├─ Click, arbitrary content → Popover
├─ Click, pick from a list → Select
└─ Typing, filter a list → Combobox (Autocomplete for free text)
Which composition mechanism?
Need to change the element a part renders?
├─ NO → className / style, done
└─ YES → does the output depend on the part's state?
├─ NO → render={<YourElement />} (element form)
└─ YES → render={(props, state) => …} (function form)
Building your own part that should accept `render` too?
└─ useRender({ defaultTagName, render, props: mergeProps(...) })
Controlled or uncontrolled?
Does anything outside need to SET the value?
├─ YES → controlled: value/open + onValueChange/onOpenChange
└─ NO → does anything outside need to OBSERVE it?
├─ YES → uncontrolled + handler only (read, don't own)
└─ NO → do you need to FORBID some changes?
├─ YES → uncontrolled + eventDetails.cancel()
└─ NO → defaultValue / defaultOpen
Where does styling state come from?
Can the rule be expressed as an attribute selector?
├─ YES → CSS: [data-open], [data-side="top"], [data-invalid]
└─ NO → does the CONTENT change with state, not just the styling?
├─ YES → render={(props, state) => …}
└─ NO → className={(state) => …} / style={(state) => …}
Which animation approach?
Is the motion a single interpolation between two states?
├─ YES → CSS transition + [data-starting-style] / [data-ending-style] (preferred)
└─ NO → multi-step motion?
├─ YES → CSS @keyframes on [data-open] / [data-closed]
└─ Driven by an external animation runtime?
├─ Popup unmounts → keepMounted on Portal + presence wrapper
├─ Popup stays in DOM → render={(props, state) => …}, animate on state.open
└─ Motion runs outside the Web Animations API → actionsRef.unmount()
Core Patterns
Pattern 1: Part Anatomy, Portal and Positioner
Every interactive component is a namespace of parts assembled by hand, and popups all follow one shape.
import { Popover } from "@base-ui/react/popover";
<Popover.Root>
<Popover.Trigger />
<Popover.Portal>
<Popover.Positioner sideOffset={8}>
<Popover.Popup>
<Popover.Arrow />
<Popover.Viewport>
<Popover.Title />
<Popover.Close />
</Popover.Viewport>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>;
Portal escapes ancestor overflow and stacking contexts, Positioner owns the collision math and carries the transform, Popup is yours to style and animate without disturbing it, Viewport keeps content stable while the popup resizes. Menus, Select, Tooltip, Preview Card and Context Menu repeat the skeleton with extra parts.
Full code: examples/core.md
Pattern 2: Styling Unstyled Parts
className and style take a plain value or a function of the part's state; data attributes carry the same information into CSS, which is where it costs nothing.
.popup[data-open] {
opacity: 1;
}
.popup[data-side="top"] {
transform-origin: bottom center;
}
.arrow[data-uncentered] {
visibility: hidden;
}
data-side is top | bottom | left | right | inline-start | inline-end and data-align is start | center | end — both report where the popup actually landed, not where you asked for it, so side-aware rules survive every collision outcome.
Full code: examples/styling.md
Pattern 3: Render-Prop Composition
render replaces the element a part produces. The element form clones and merges for you; the function form hands you the merged props to spread yourself.
// Element form — change the tag, or hand over your own component
<Menu.Item render={<a href="/library" />}>Add to Library</Menu.Item>
// Function form — full control, and content that varies by state
<Switch.Thumb
render={(props, state) => (
<span {...props}>{state.checked ? <CheckedIcon /> : <UncheckedIcon />}</span>
)}
/>
className strings are concatenated, style objects merged and handlers chained — but only onto a target that forwards ref and spreads what it receives.
Full code: examples/composition.md
Pattern 4: Building Your Own Parts
useRender plus mergeProps gives a component you write the same render API the library's parts have.
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
export function Text({ render, ...otherProps }: useRender.ComponentProps<"p">) {
return useRender({
defaultTagName: "p",
render,
props: mergeProps<"p">({ className: "text" }, otherProps),
});
}
mergeProps merges right-to-left — handlers run rightmost-first, className concatenates rightmost-first, rightmost style keys win — and it does not merge ref. Every ref that needs the node goes to useRender's ref parameter, which takes one ref or an array. event.preventBaseUIHandler() suppresses the library's own handler from inside a merged one.
Full code: examples/composition.md
Pattern 5: Controlled vs Uncontrolled, and Canceling Changes
Components are uncontrolled by default. Every change handler takes eventDetails as its second argument, whose reason says why the change was requested and whose cancel() refuses it — on an uncontrolled component too.
<Tooltip.Root
eventDetails) => {
if (eventDetails.reason === "trigger-press") {
eventDetails.cancel();
}
}}
/>
Branch on reason rather than inferring intent from the new value. isCanceled only reports whether another handler in the chain already cancelled. onOpenChangeComplete fires after animations settle, and actionsRef exposes imperative close() and unmount().
Full code: examples/state.md
Pattern 6: Fields, Fieldsets and Forms
Field wires a label, a control, a description and an error message together with the ARIA relationships. Form collects external errors and maps them onto fields by name.
<Form errors={errors}
<Fieldset.Root>
<Fieldset.Legend>Contact</Fieldset.Legend>
<Field.Root name="url" validationMode="onBlur">
<Field.Label>Homepage</Field.Label>
<Field.Control type="url" required />
<Field.Error />
</Field.Root>
</Fieldset.Root>
</Form>
Field.Root publishes data-valid, data-invalid, data-dirty, data-touched, data-filled, data-focused and data-disabled, so the whole group styles from one element. name on Field.Root puts the control into native FormData — non-native controls render hidden inputs to participate. validationMode is onSubmit (default), onBlur or onChange, and validationDebounceTime throttles an async validate.
Full code: examples/forms.md
Pattern 7: Popup Positioning Essentials
Positioner is the whole positioning API. Defaults: side="bottom", align="center", sideOffset={0}, collisionPadding={5}, sticky={false}, positionMethod="absolute", collisionBoundary="clipping-ancestors".
<Popover.Positioner
side="top"
align="start"
sideOffset={8}
collisionPadding={16}
sticky
>
<Popover.Popup>
<Popover.Arrow />
</Popover.Popup>
</Popover.Positioner>
sideOffsetis the anchor-to-popup gap — at least the arrow's height, or the arrow overlaps the trigger.collisionBoundaryandcollisionPaddingdefine the box the popup stays inside; raise the padding when a fixed header would clip it.stickykeeps the popup in view as the anchor scrolls away, instead of letting it drift off with the anchor.positionMethod="fixed"is the fix for an anchor inside a transformed orcontain-ed ancestor.anchorpositions against an arbitrary element or virtual rect, which is how cursor- and selection-anchored popups are built.- Popups publish
--anchor-width,--available-height,--popup-widthand--popup-heightfor width-matching and max-height rules.
Full code: examples/core.md
Red flags
Breaks at runtime:
- Installing
@base-ui-components/react— frozen at a release candidate, with APIs that differ from the maintained@base-ui/react. - Reaching for
asChild— it does not exist; the part renders its default element and your child is ignored or duplicated. - A
rendertarget that neither spreads props nor forwardsref— the popup never opens, positioning never attaches, ARIA wiring is lost, and nothing throws. - Positioning props on
Popupinstead ofPositioner— not part ofPopup's API, so they land on the DOM node as unknown attributes. - Omitting
Portal— ancestoroverflow: hiddenclips the popup and ancestor stacking contexts trap it. - A controlled component with
opensupplied and noonOpenChange— escape, outside press andCloseall request a change nobody listens for, so the user is trapped. - Returning early from a change handler to block a change — the return value is ignored and an uncontrolled component still updates. Call
eventDetails.cancel().
Surprising behaviour:
- A
transformonPositioneroverwrites the computed placement — put your own transforms onPopup, or express the gap assideOffset. - Mirroring
openorvalueinto React state to drive CSS — the data attributes already carry it, the copy drifts, and it updates a frame later than the attribute. sideOffset={0}with anArrow— the arrow overlaps the trigger.- A hard-coded popup
max-heightinstead of--available-height— overflows short viewports. data-uncenteredunhandled onArrow— after collision handling the arrow points at nothing.keepMountedwith nothing driving unmount — hidden markup stays in the DOM and in the accessibility tree.defaultValueis read once; changing it later warns in development and does nothing.isCanceledreports that some handler already calledcancel(); assigning to it cancels nothing.data-starting-styleanddata-ending-styleexist only during the transition — a rule written without them applies to the resting state too, which is why transitions here are recommended over@keyframes: a transition can also be cancelled mid-flight when a closing popup reopens.- Unmount waits on
element.getAnimations(), so motion applied to a child ofPopupdoes not delay it — animatePopupitself or callactionsRef.unmount(). Select.PositionerdefaultsalignItemWithTriggertotrue, overriding ordinaryside/alignexpectations, and switches itself off on touch or in a small viewport — so the popup positions differently across devices.Menu.Itemcloses the menu on click by default;closeOnClick={false}is for items that toggle something.Menu.Itemrenders a non-native button by default — setnativeButtonwhere a real<button>matters.Fieldset.Legendrenders a<div>, not a<legend>; select it by class.mergePropsruns handlers right-to-left, the opposite of most merge helpers.