Skip to main content

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:
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:
ApproachExample
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

Build docs developers (and LLMs) love