Vercel Logo

To truly understand how Radix UI primitives work, we need to examine their anatomy in detail. By breaking down a primitive component, you'll learn to recognize the patterns, understand the relationships between parts, and see how they create robust, accessible behavior.

Let's dissect the Dialog primitive as our example – it's complex enough to demonstrate key concepts while being familiar to most developers.

The Complete Dialog Primitive Structure

Here's the full structure of a Dialog primitive before any shadcn/ui styling:

Reactmy-dialog.tsx
import * as Dialog from "@radix-ui/react-dialog"

function DialogExample() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Open Dialog</Dialog.Trigger>
      
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Dialog Title</Dialog.Title>
          <Dialog.Description>
            This is the dialog description that provides additional context.
          </Dialog.Description>
          
          {/* Your custom content goes here */}
          <div>
            <p>Dialog body content...</p>
          </div>
          
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Let's examine each component and understand its role:

The Root Component

Reactmy-dialog.tsx
<Dialog.Root>

The Root component is the state container and context provider for the entire dialog. It doesn't render any DOM elements – it's purely for coordination. It is responsible for:

  • Managing the open/closed state
  • Providing context to all child components
  • Handling the overall state transitions
  • Coordinating between trigger, content, and overlay

It takes props to control the state of the dialog.

Reactmy-dialog.tsx
<Dialog.Root
  open={boolean}           // Controlled open state
  defaultOpen={boolean}    // Uncontrolled default state
  onOpenChange={function}  // Callback when state changes
  modal={boolean}          // Whether dialog is modal (default: true)
>

The Root component follows React's compound component pattern. It provides context that all child components consume, creating a coordinated system.

Compound Component Pattern

Radix primitives use the compound component pattern extensively. The Root component provides context, and child components consume that context to coordinate their behavior. This pattern provides flexibility while maintaining component relationships. This is a key reason why shadcn/ui components are so flexible and easy to customize.

The Trigger Component

Reactmy-dialog.tsx
<Dialog.Trigger>Open Dialog</Dialog.Trigger>

The Trigger component is the element that opens the dialog when activated. It is responsible for:

  • Rendering as a button by default (can be customized with asChild)
  • Handling click events to open the dialog
  • Managing focus when dialog closes
  • Providing proper ARIA attributes

It generates the following attributes:

<button 
  type="button"
  aria-haspopup="dialog"
  aria-expanded="false"
  data-state="closed"
>
  Open Dialog
</button>

It takes props to customize the trigger.

Reactmy-dialog.tsx
<Dialog.Trigger
  asChild={boolean}  // Render as child element instead of button
>

The Portal Component

The Portal component is responsible for rendering the dialog content outside the normal DOM hierarchy to avoid z-index and overflow issues.

Reactmy-dialog.tsx
<Dialog.Portal>

It is responsible for:

  • Rendering content at the end of the document body (by default)
  • Ensuring the dialog appears above all other content
  • Handling portal cleanup when the dialog unmounts

Without portals, dialogs can be clipped by parent containers with overflow: hidden or appear behind other elements due to z-index stacking contexts.

It takes props to customize the portal.

Reactmy-dialog.tsx
<Dialog.Portal
  container={HTMLElement}  // Custom portal container
  forceMount={boolean}     // Always mount (useful for animations)
>

The Overlay Component

The Overlay component is the backdrop/overlay that appears behind the dialog content. It is responsible for:

Reactmy-dialog.tsx
<Dialog.Overlay />
  • Providing visual separation from the page content
  • Handling click-outside-to-close behavior
  • Styling for visual effects (blur, darken, etc.)

It generates the following attributes:

<div 
  data-state="open"
  style="pointer-events: auto;"
>
</div>

The overlay is a separate component so you can style it independently and control its behavior (like disabling click-outside-to-close).

The Content Component

The Content component is the main container for the dialog's content. It is responsible for:

Reactmy-dialog.tsx
<Dialog.Content>
  • Focus management and focus trapping
  • Keyboard event handling (Escape to close)
  • ARIA attributes for accessibility
  • Animation and transition coordination

It generates the following attributes:

<div 
  role="dialog"
  aria-describedby="radix-1"
  aria-labelledby="radix-2"
  data-state="open"
  tabindex="-1"
  style="pointer-events: auto;"
>

It takes props to customize the content.

Reactmy-dialog.tsx
<Dialog.Content
  forceMount={boolean}           // Always mount for animations
  onOpenAutoFocus={function}     // Custom focus behavior on open
  onCloseAutoFocus={function}    // Custom focus behavior on close
  onEscapeKeyDown={function}     // Custom escape key behavior
  onPointerDownOutside={function} // Custom click-outside behavior
  onInteractOutside={function}   // Custom interact-outside behavior
>
Question
What is the primary purpose of the Dialog.Portal component?

The Title and Description Components

The Title and Description components are used to provide accessible labels for the dialog content.

Reactmy-dialog.tsx
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Dialog description</Dialog.Description>

They are responsible for:

  • Title: Main heading/label for the dialog
  • Description: Additional context about the dialog's purpose
  • Both automatically link to the dialog via ARIA attributes.

They generate the following attributes:

<!-- Title -->
<h2 id="radix-2">Dialog Title</h2>

<!-- Description -->  
<p id="radix-1">Dialog description</p>

<!-- Content references both -->
<div 
  role="dialog"
  aria-labelledby="radix-2"
  aria-describedby="radix-1"
>

These components automatically create proper ARIA relationships. The dialog is labeled by the title and described by the description, improving screen reader experience.

The Close Component

The Close component is an element that closes the dialog when activated.

Reactmy-dialog.tsx
<Dialog.Close>Close</Dialog.Close>

It is responsible for:

  • Rendering as a button by default
  • Handling click events to close the dialog
  • Providing proper ARIA attributes

It generates the following attributes:

<button type="button">Close</button>

State Management and Data Attributes

Primitives use data attributes to communicate state to CSS and JavaScript. This is a key part of the primitive pattern.

Reactmy-dialog.tsx
// When dialog is closed
<Dialog.Trigger data-state="closed" />
<Dialog.Overlay data-state="closed" />
<Dialog.Content data-state="closed" />

// When dialog is open
<Dialog.Trigger data-state="open" />
<Dialog.Overlay data-state="open" />
<Dialog.Content data-state="open" />

This allows for state-based styling.

CSSdialog.css
[data-state="open"] {
  animation: fadeIn 200ms ease-out;
}

[data-state="closed"] {
  animation: fadeOut 200ms ease-out;
}

Event Flow and Coordination

Here's how the components coordinate during typical interactions:

Opening the Dialog

  1. User clicks Dialog.Trigger
  2. Dialog.Root updates its internal state to "open"
  3. All components receive new state via context
  4. Dialog.Portal mounts the overlay and content
  5. Dialog.Content receives focus
  6. Focus is trapped within the dialog

Closing the Dialog

  1. User clicks Dialog.Close, presses Escape, or clicks outside
  2. Dialog.Root updates its internal state to "closed"
  3. All components receive new state via context
  4. Focus returns to the original trigger
  5. Dialog.Portal unmounts the overlay and content

Controlled vs. Uncontrolled Usage

Primitives support both controlled and uncontrolled usage patterns:

Uncontrolled (Primitive manages state)

This is the default usage pattern. The primitive manages the state internally.

Reactmy-dialog.tsx
<Dialog.Root defaultOpen={false}>
  {/* Primitive handles all state internally */}
</Dialog.Root>

Controlled (You manage state)

This is the recommended usage pattern. You control the state externally.

Reactmy-dialog.tsx
const [open, setOpen] = useState(false)

<Dialog.Root open={open} onOpenChange={setOpen}>
  {/* You control the state */}
</Dialog.Root>

Semi-controlled (Listen to state changes)

This is a semi-controlled usage pattern. You listen to state changes and perform side effects.

Reactmy-dialog.tsx
<Dialog.Root onOpenChange={(open) => {
  console.log('Dialog state changed:', open)
  // Perform side effects, analytics, etc.
}}>
  {/* Primitive manages state, you listen to changes */}
</Dialog.Root>

Customization Patterns

Using asChild for Custom Elements

This is a common pattern to use custom elements as the trigger. This is useful when you want to use a custom element as the trigger, but you want to use the primitive's behavior.

Reactmy-dialog.tsx
<Dialog.Trigger asChild>
  <Button variant="outline">
    Custom Trigger Button
  </Button>
</Dialog.Trigger>

Custom Event Handling

This is a common pattern to handle custom events. This is useful when you want to handle custom events, such as onEscapeKeyDown or onPointerDownOutside.

Reactmy-dialog.tsx
<Dialog.Content
  onEscapeKeyDown={(event) => {
    // Custom escape behavior
    if (hasUnsavedChanges) {
      event.preventDefault()
      showConfirmDialog()
    }
  }}
  onPointerDownOutside={(event) => {
    // Custom click-outside behavior
    if (isFormDirty) {
      event.preventDefault()
    }
  }}
>
Reflection Prompt
Understanding Component Relationships

Looking at the Dialog primitive anatomy, can you identify how the different components work together? What would happen if you removed the Portal component? How do the Title and Description components improve accessibility?

Common Patterns Across Primitives

Now that you understand Dialog anatomy, you'll recognize these patterns in other primitives:

  • Root + Context Pattern: Almost all primitives have a Root component that provides context to children.
  • Trigger Pattern: Many primitives have elements that trigger state changes (buttons, menu items, etc.).
  • Content Pattern: Complex primitives have a main content area that receives focus and handles keyboard navigation.
  • Portal Pattern: Floating content (dialogs, popovers, dropdowns) uses portals for proper layering.
  • State Data Attributes: All primitives use data attributes to communicate state for styling and scripting.

What This Means for shadcn/ui

Understanding primitive anatomy helps you understand shadcn/ui components because:

  1. shadcn/ui components follow the same structure – they're styled versions of primitives.
  2. You can customize any part – understanding the anatomy shows you what's possible.
  3. You can debug issues – knowing how primitives work helps troubleshoot problems.
  4. You can extend functionality – understanding the patterns lets you build your own components.

What's Next

Now that you understand the fundamental concepts of shadcn/ui and how primitives work, you're ready to get hands-on with the library. In the next chapter, we'll walk through installing shadcn/ui in your project and adding your first components.