What is Composition?
Radix UI Primitives are built with a composable API design. This means components are designed to be combined and nested to create complex UI patterns while maintaining full control over the rendered markup.
The key principle: one component renders one DOM element (or no DOM element at all).
The Composable API
Instead of a single component with many props, Radix provides multiple sub-components that work together:
Radix Approach (Composable)
Traditional Approach (Props-based)
import * as Dialog from '@radix-ui/react-dialog' ;
function MyDialog () {
return (
< Dialog.Root >
< Dialog.Trigger > Open </ Dialog.Trigger >
< Dialog.Portal >
< Dialog.Overlay />
< Dialog.Content >
< Dialog.Title > Dialog Title </ Dialog.Title >
< Dialog.Description > Dialog description </ Dialog.Description >
< Dialog.Close > Close </ Dialog.Close >
</ Dialog.Content >
</ Dialog.Portal >
</ Dialog.Root >
);
}
Benefits of Composition
Flexibility Insert your own elements anywhere in the component tree. Add wrappers, change order, or customize markup.
Control Direct access to each DOM element means full control over styling, event handlers, and attributes.
Predictability Easy to understand what HTML will be rendered. One component = one DOM element.
Simplicity No complex prop configurations. Compose the pieces you need like building blocks.
1-to-1 Mapping
Each Radix component maps directly to a single DOM element:
// Switch.Root renders a <button>
< Switch.Root className = "my-switch" >
{ /* Switch.Thumb renders a <span> */ }
< Switch.Thumb className = "my-thumb" />
</ Switch.Root >
// Resulting HTML:
// <button class="my-switch">
// <span class="my-thumb"></span>
// </button>
This 1-to-1 mapping means:
Refs work as expected - Forward a ref to a Radix component, and it points to the actual DOM node
Event handlers work naturally - Add onClick, onMouseEnter, etc., directly to components
Styling is straightforward - Apply classes or styles exactly where you need them
Inspection is easy - The React component tree matches the DOM tree
Some components like Dialog.Root and Accordion.Root don’t render any DOM element - they only manage state and context.
Composition Patterns
Basic Composition
Nest components to build the structure you need:
import * as Accordion from '@radix-ui/react-accordion' ;
function FAQ () {
return (
< Accordion.Root type = "single" collapsible >
< Accordion.Item value = "item-1" >
< Accordion.Header >
< Accordion.Trigger >
What is Radix UI?
</ Accordion.Trigger >
</ Accordion.Header >
< Accordion.Content >
A collection of accessible, unstyled React components.
</ Accordion.Content >
</ Accordion.Item >
< Accordion.Item value = "item-2" >
< Accordion.Header >
< Accordion.Trigger >
How do I install it?
</ Accordion.Trigger >
</ Accordion.Header >
< Accordion.Content >
Use npm install @radix-ui/react-accordion
</ Accordion.Content >
</ Accordion.Item >
</ Accordion.Root >
);
}
Adding Custom Elements
You can add your own elements anywhere:
import * as Dialog from '@radix-ui/react-dialog' ;
function CustomDialog () {
return (
< Dialog.Root >
< Dialog.Trigger > Open </ Dialog.Trigger >
< Dialog.Portal >
< Dialog.Overlay />
< Dialog.Content >
{ /* Add a custom header wrapper */ }
< div className = "dialog-header" >
< Dialog.Title > Settings </ Dialog.Title >
< Dialog.Close >
< CloseIcon />
</ Dialog.Close >
</ div >
{ /* Add a custom body wrapper */ }
< div className = "dialog-body" >
< Dialog.Description >
Configure your application settings.
</ Dialog.Description >
{ /* Your custom form or content */ }
< SettingsForm />
</ div >
{ /* Add a custom footer */ }
< div className = "dialog-footer" >
< button > Cancel </ button >
< button > Save </ button >
</ div >
</ Dialog.Content >
</ Dialog.Portal >
</ Dialog.Root >
);
}
Conditional Rendering
Components work naturally with conditional rendering:
import * as AlertDialog from '@radix-ui/react-alert-dialog' ;
function DeleteDialog ({ showCancel = true }) {
return (
< AlertDialog.Root >
< AlertDialog.Trigger > Delete </ AlertDialog.Trigger >
< AlertDialog.Portal >
< AlertDialog.Overlay />
< AlertDialog.Content >
< AlertDialog.Title > Are you sure? </ AlertDialog.Title >
< AlertDialog.Description >
This action cannot be undone.
</ AlertDialog.Description >
< div className = "actions" >
{ showCancel && (
< AlertDialog.Cancel > Cancel </ AlertDialog.Cancel >
) }
< AlertDialog.Action > Delete </ AlertDialog.Action >
</ div >
</ AlertDialog.Content >
</ AlertDialog.Portal >
</ AlertDialog.Root >
);
}
Mapping Over Data
Easily create dynamic lists:
import * as Tabs from '@radix-ui/react-tabs' ;
const tabs = [
{ id: 'account' , label: 'Account' , content: < AccountPanel /> },
{ id: 'password' , label: 'Password' , content: < PasswordPanel /> },
{ id: 'billing' , label: 'Billing' , content: < BillingPanel /> },
];
function Settings () {
return (
< Tabs.Root defaultValue = "account" >
< Tabs.List >
{ tabs . map ( tab => (
< Tabs.Trigger key = { tab . id } value = { tab . id } >
{ tab . label }
</ Tabs.Trigger >
)) }
</ Tabs.List >
{ tabs . map ( tab => (
< Tabs.Content key = { tab . id } value = { tab . id } >
{ tab . content }
</ Tabs.Content >
)) }
</ Tabs.Root >
);
}
The asChild Prop
One of the most powerful composition features is the asChild prop. It allows you to merge Radix’s functionality with your own elements.
Without asChild
By default, Radix components render their own element:
< Dialog.Trigger >
Open Dialog
</ Dialog.Trigger >
// Renders:
// <button type="button">Open Dialog</button>
With asChild
Use asChild to merge functionality into your own element:
< Dialog.Trigger asChild >
< button className = "my-custom-button" >
< Icon /> Open Dialog
</ button >
</ Dialog.Trigger >
// Renders:
// <button type="button" class="my-custom-button">
// <svg>...</svg>Open Dialog
// </button>
The asChild prop uses the Slot component under the hood to merge props, refs, and event handlers.
Using with Custom Components
You can use asChild with your own components:
import { Link } from 'react-router-dom' ;
< NavigationMenu.Link asChild >
< Link to = "/about" > About </ Link >
</ NavigationMenu.Link >
// Radix's functionality is merged with your Link component
Using with Icons and Wrappers
import * as Checkbox from '@radix-ui/react-checkbox' ;
import { CheckIcon } from '@radix-ui/react-icons' ;
function CustomCheckbox () {
return (
< Checkbox.Root className = "checkbox-root" >
< Checkbox.Indicator asChild >
< CheckIcon />
</ Checkbox.Indicator >
</ Checkbox.Root >
);
}
Accessing DOM Refs
Since components map 1-to-1 with DOM elements, refs work naturally:
import * as Dialog from '@radix-ui/react-dialog' ;
import { useRef , useEffect } from 'react' ;
function MyDialog () {
const contentRef = useRef < HTMLDivElement >( null );
useEffect (() => {
// Access the actual DOM node
if ( contentRef . current ) {
console . log ( 'Dialog content element:' , contentRef . current );
}
}, []);
return (
< Dialog.Root >
< Dialog.Trigger > Open </ Dialog.Trigger >
< Dialog.Portal >
< Dialog.Content ref = { contentRef } >
< Dialog.Title > My Dialog </ Dialog.Title >
</ Dialog.Content >
</ Dialog.Portal >
</ Dialog.Root >
);
}
Event Handler Composition
You can add your own event handlers, and they’ll be composed with Radix’s internal handlers:
import * as Switch from '@radix-ui/react-switch' ;
function TrackedSwitch () {
return (
< Switch.Root
onCheckedChange = { ( checked ) => {
console . log ( 'Switch changed:' , checked );
// Your logic here
trackEvent ( 'switch_toggled' , { value: checked });
} }
onClick = { ( event ) => {
console . log ( 'Switch clicked:' , event );
// This runs alongside Radix's internal click handler
} }
>
< Switch.Thumb />
</ Switch.Root >
);
}
Event handlers are composed, not replaced. Both your handler and Radix’s internal handler will run.
Controlled vs Uncontrolled
Composition works with both controlled and uncontrolled patterns:
Uncontrolled (Internal State)
import * as Collapsible from '@radix-ui/react-collapsible' ;
function UncontrolledCollapsible () {
return (
< Collapsible.Root defaultOpen = { true } >
< Collapsible.Trigger >
Toggle
</ Collapsible.Trigger >
< Collapsible.Content >
Content that can be collapsed
</ Collapsible.Content >
</ Collapsible.Root >
);
}
Controlled (External State)
import * as Collapsible from '@radix-ui/react-collapsible' ;
import { useState } from 'react' ;
function ControlledCollapsible () {
const [ open , setOpen ] = useState ( false );
return (
< div >
< p > Currently: { open ? 'Open' : 'Closed' } </ p >
< Collapsible.Root open = { open } onOpenChange = { setOpen } >
< Collapsible.Trigger >
Toggle
</ Collapsible.Trigger >
< Collapsible.Content >
Content that can be collapsed
</ Collapsible.Content >
</ Collapsible.Root >
</ div >
);
}
Building Wrapper Components
You can create your own wrapper components while maintaining composability:
import * as Dialog from '@radix-ui/react-dialog' ;
interface ConfirmDialogProps {
title : string ;
description : string ;
onConfirm : () => void ;
onCancel ?: () => void ;
children : React . ReactNode ;
}
export function ConfirmDialog ({
title ,
description ,
onConfirm ,
onCancel ,
children
} : ConfirmDialogProps ) {
return (
< Dialog.Root >
< Dialog.Trigger asChild >
{ children }
</ Dialog.Trigger >
< Dialog.Portal >
< Dialog.Overlay className = "overlay" />
< Dialog.Content className = "content" >
< Dialog.Title > { title } </ Dialog.Title >
< Dialog.Description > { description } </ Dialog.Description >
< div className = "actions" >
< Dialog.Close onClick = { onCancel } >
Cancel
</ Dialog.Close >
< Dialog.Close onClick = { onConfirm } >
Confirm
</ Dialog.Close >
</ div >
</ Dialog.Content >
</ Dialog.Portal >
</ Dialog.Root >
);
}
// Usage:
< ConfirmDialog
title = "Delete Account"
description = "This action is permanent"
onConfirm = { deleteAccount }
>
< button > Delete My Account </ button >
</ ConfirmDialog >
When building wrapper components, make sure to forward refs and preserve accessibility props.
Composition Anti-Patterns
Avoid these common mistakes:
Don’t Skip Required Parts
// ❌ Bad: Missing required components
< Dialog.Root >
< Dialog.Trigger > Open </ Dialog.Trigger >
{ /* Missing Portal, Content */ }
</ Dialog.Root >
// ✅ Good: Include all required components
< Dialog.Root >
< Dialog.Trigger > Open </ Dialog.Trigger >
< Dialog.Portal >
< Dialog.Content >
< Dialog.Title > Title </ Dialog.Title >
</ Dialog.Content >
</ Dialog.Portal >
</ Dialog.Root >
Don’t Break Component Hierarchy
// ❌ Bad: Components in wrong order
< Accordion.Root >
< Accordion.Trigger > Click me </ Accordion.Trigger >
< Accordion.Item value = "item-1" >
< Accordion.Content > Content </ Accordion.Content >
</ Accordion.Item >
</ Accordion.Root >
// ✅ Good: Correct hierarchy
< Accordion.Root >
< Accordion.Item value = "item-1" >
< Accordion.Trigger > Click me </ Accordion.Trigger >
< Accordion.Content > Content </ Accordion.Content >
</ Accordion.Item >
</ Accordion.Root >
Composition vs Configuration
Radix favors composition over configuration:
Approach Example Configuration (props)<Dialog title="My Dialog" showClose={true} size="large" />Composition (components)<Dialog.Root><Dialog.Content><Dialog.Title>My Dialog</Dialog.Title></Dialog.Content></Dialog.Root>
Composition wins because:
More flexible - you control the markup
More predictable - you see exactly what renders
Better TypeScript support - each component has specific types
Easier to customize - no need to learn complex prop APIs
Next Steps
Accessibility Learn about accessibility features
Slot Component Deep dive into the Slot utility
Components Browse all available components
Styling Learn about styling approaches