Overview
Composability is at the heart of Radix UI Primitives. Every component is designed to compose naturally, just like native HTML elements, giving you complete control over the DOM structure and behavior.
Radix components follow a 1-to-1 mapping principle: each component renders exactly one DOM element (unless explicitly documented otherwise).
1-to-1 DOM Mapping
The fundamental principle of composability in Radix is that each component maps to a single DOM node.
What This Means
When you write:
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger>Toggle</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
Content here
</Accordion.Content>
</Accordion.Item>
You get exactly this DOM structure:
<div data-state="closed"> <!-- Accordion.Item -->
<h3> <!-- Accordion.Header -->
<button> <!-- Accordion.Trigger -->
Toggle
</button>
</h3>
<div> <!-- Accordion.Content -->
Content here
</div>
</div>
Benefits
- Predictable - You know exactly what gets rendered
- Inspectable - DevTools show the real DOM structure
- Styleable - Direct access to elements for CSS
- Debuggable - No hidden wrapper elements to confuse you
Because each component renders one element, you can reason about the DOM structure by reading the JSX.
Exceptions Are Documented
When a component deviates from this pattern (like Dialog.Portal which renders to a different part of the DOM), it’s clearly documented with rationale.
Ref Forwarding
Refs are forwarded to the underlying DOM element, working exactly as you’d expect with native elements.
Basic Ref Usage
import { useRef } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
function MyDialog() {
const triggerRef = useRef(null);
const contentRef = useRef(null);
return (
<Dialog.Root>
<Dialog.Trigger ref={triggerRef}>
Open Dialog
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content ref={contentRef}>
<Dialog.Title>Title</Dialog.Title>
{/* ... */}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Now:
triggerRef.current is the <button> element
contentRef.current is the <div> element containing the dialog content
Accessing DOM Methods
Because refs point to real DOM nodes, you can use any DOM API:
const inputRef = useRef(null);
// Focus the input
inputRef.current?.focus();
// Get bounding box
const rect = inputRef.current?.getBoundingClientRect();
// Scroll into view
inputRef.current?.scrollIntoView();
Ref Composition
Radix uses the useComposedRefs hook internally to merge multiple refs. This is from packages/react/compose-refs/src/compose-refs.tsx:55:
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return React.useCallback(composeRefs(...refs), refs);
}
This means internal refs (used for behavior) and your refs both work simultaneously.
The asChild Prop
The asChild prop is the key to advanced composition. It tells Radix to merge its functionality with your own element instead of rendering a default one.
Without asChild
<Dialog.Trigger className="my-button">
Open Dialog
</Dialog.Trigger>
Renders:
<button class="my-button" type="button" aria-haspopup="dialog">
Open Dialog
</button>
With asChild
<Dialog.Trigger asChild>
<a href="/dialog" className="my-link">
Open Dialog
</a>
</Dialog.Trigger>
Renders:
<a href="/dialog" class="my-link" role="button" aria-haspopup="dialog">
Open Dialog
</a>
Radix merges its props (event handlers, aria attributes, etc.) with your element’s props. Your element’s props take precedence where appropriate.
How It Works
The asChild prop leverages Radix’s Slot component (from packages/react/slot/src/slot.tsx:45):
const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
const childrenArray = React.Children.toArray(children);
const slottable = childrenArray.find(isSlottable);
if (slottable) {
const newElement = slottable.props.children;
return (
<SlotClone {...slotProps} ref={forwardedRef}>
{React.isValidElement(newElement)
? React.cloneElement(newElement, undefined, newChildren)
: null}
</SlotClone>
);
}
return (
<SlotClone {...slotProps} ref={forwardedRef}>
{children}
</SlotClone>
);
});
The Slot component:
- Takes the child element you provide
- Clones it with Radix’s props merged in
- Forwards refs correctly
Event Handler Composition
Event handlers compose automatically, allowing you to add custom logic without breaking built-in behavior.
Adding Your Own Handlers
<Accordion.Trigger
onClick={(event) => {
console.log('Trigger clicked!');
// Radix's internal onClick still runs
}}
>
Toggle
</Accordion.Trigger>
Handler Execution Order
- Your handler runs first
- Radix’s internal handler runs second
- Event propagates unless stopped
Preventing Default Behavior
You can prevent Radix’s behavior when needed:
<Dialog.Trigger
onClick={(event) => {
if (someCondition) {
event.preventDefault(); // Stops dialog from opening
}
}}
>
Conditional Open
</Dialog.Trigger>
How It Works
Radix uses composeEventHandlers internally (from @radix-ui/primitive):
function composeEventHandlers<E>(
originalEventHandler?: (event: E) => void,
ourEventHandler?: (event: E) => void,
{ checkForDefaultPrevented = true } = {}
) {
return function handleEvent(event: E) {
originalEventHandler?.(event);
if (
checkForDefaultPrevented === false ||
!(event as unknown as Event).defaultPrevented
) {
return ourEventHandler?.(event);
}
};
}
This ensures:
- Your handler runs first
- Radix respects
preventDefault()
- Event propagation works naturally
Composition Patterns
Pattern 1: Wrapping with Custom Components
Create your own components that wrap Radix:
// components/Button.jsx
import * as Dialog from '@radix-ui/react-dialog';
export function DialogButton({ children, ...props }) {
return (
<Dialog.Trigger asChild>
<button className="btn btn-primary" {...props}>
{children}
</button>
</Dialog.Trigger>
);
}
// Usage
<Dialog.Root>
<DialogButton>Open</DialogButton>
{/* ... */}
</Dialog.Root>
Pattern 2: Polymorphic Components
Create components that can render as different elements:
function Button({ as: Comp = 'button', children, ...props }) {
return (
<Dialog.Trigger asChild>
<Comp className="btn" {...props}>
{children}
</Comp>
</Dialog.Trigger>
);
}
// Render as button
<Button>Click me</Button>
// Render as link
<Button as="a" href="/page">Go to page</Button>
Pattern 3: Animation Libraries
Compose with animation libraries like Framer Motion:
import { motion } from 'framer-motion';
import * as Dialog from '@radix-ui/react-dialog';
function AnimatedDialog() {
return (
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay asChild>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
>
<Dialog.Title>Animated Dialog</Dialog.Title>
{/* ... */}
</motion.div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Pattern 4: Extending with Additional Props
Add your own props while preserving Radix functionality:
function AccordionItem({ value, children, icon, badge }) {
return (
<Accordion.Item value={value}>
<Accordion.Header>
<Accordion.Trigger>
<span className="trigger-content">
{icon && <span className="icon">{icon}</span>}
<span className="label">{children}</span>
{badge && <span className="badge">{badge}</span>}
</span>
</Accordion.Trigger>
</Accordion.Header>
{/* ... */}
</Accordion.Item>
);
}
// Usage
<AccordionItem
value="item-1"
icon={<IconChevron />}
badge={<Badge>New</Badge>}
>
Click me
</AccordionItem>
Prop Merging Behavior
When using asChild, props are merged intelligently:
Event Handlers
Both handlers run (yours first, then Radix’s):
<Dialog.Trigger asChild>
<button onClick={myHandler}>
Open
</button>
</Dialog.Trigger>
Both myHandler and Radix’s internal handler execute.
Styles
Style objects are merged:
<Dialog.Trigger asChild>
<button style={{ color: 'red', padding: 16 }}>
Open
</button>
</Dialog.Trigger>
Radix’s styles (if any) merge with yours.
Class Names
Class names are concatenated:
<Dialog.Trigger asChild>
<button className="my-button">
Open
</button>
</Dialog.Trigger>
Results in className="my-button [radix-classes]".
Other Props
Child props take precedence:
<Dialog.Trigger asChild>
<button type="submit"> {/* type="submit" wins over type="button" */}
Open
</button>
</Dialog.Trigger>
From packages/react/slot/src/slot.tsx:164:
function mergeProps(slotProps: AnyProps, childProps: AnyProps) {
const overrideProps = { ...childProps };
for (const propName in childProps) {
const slotPropValue = slotProps[propName];
const childPropValue = childProps[propName];
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler) {
if (slotPropValue && childPropValue) {
overrideProps[propName] = (...args: unknown[]) => {
childPropValue(...args);
slotPropValue(...args);
};
} else if (slotPropValue) {
overrideProps[propName] = slotPropValue;
}
} else if (propName === 'style') {
overrideProps[propName] = { ...slotPropValue, ...childPropValue };
} else if (propName === 'className') {
overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
}
}
return { ...slotProps, ...overrideProps };
}
Real-World Examples
Example 1: Custom Accordion Trigger
import * as Accordion from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
function CustomAccordion() {
return (
<Accordion.Root type="single" collapsible>
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger className="accordion-trigger">
<span>What is Radix?</span>
<ChevronDownIcon aria-hidden />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="accordion-content">
Radix is a library of unstyled, accessible components.
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
}
Example 2: Dialog with Custom Close Button
import * as Dialog from '@radix-ui/react-dialog';
import { Cross1Icon } from '@radix-ui/react-icons';
function MyDialog() {
const closeButtonRef = useRef(null);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="btn-primary">Open Settings</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title>Settings</Dialog.Title>
<Dialog.Description>
Configure your preferences here.
</Dialog.Description>
{/* Dialog content */}
<div className="settings-form">
{/* Form fields */}
</div>
<Dialog.Close asChild>
<button
ref={closeButtonRef}
className="icon-button"
aria-label="Close"
onClick={() => console.log('Dialog closing')}
>
<Cross1Icon />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Example 3: Checkbox with Label Composition
import * as Checkbox from '@radix-ui/react-checkbox';
import * as Label from '@radix-ui/react-label';
import { CheckIcon } from '@radix-ui/react-icons';
function CheckboxWithLabel({ id, label }) {
return (
<div className="checkbox-wrapper">
<Checkbox.Root className="checkbox-root" id={id}>
<Checkbox.Indicator className="checkbox-indicator">
<CheckIcon />
</Checkbox.Indicator>
</Checkbox.Root>
<Label.Root className="checkbox-label" htmlFor={id}>
{label}
</Label.Root>
</div>
);
}
Summary
Radix UI’s composability gives you:
- Direct DOM access through 1-to-1 component mapping
- Predictable refs that point to actual DOM elements
- Flexible composition via the
asChild prop
- Natural event handling with automatic handler composition
- Full control over rendering and behavior
Think of Radix components as enhanced HTML elements—they behave like native elements but with accessibility and interaction patterns built in.
Related Concepts