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:
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
<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.
<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
<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.
<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.
<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.
<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:
<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:
<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.
<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
>
The Title and Description Components
The Title and Description components are used to provide accessible labels for the dialog content.
<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.
<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.
// 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.
[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
- User clicks
Dialog.Trigger
Dialog.Root
updates its internal state to "open"- All components receive new state via context
Dialog.Portal
mounts the overlay and contentDialog.Content
receives focus- Focus is trapped within the dialog
Closing the Dialog
- User clicks
Dialog.Close
, presses Escape, or clicks outside Dialog.Root
updates its internal state to "closed"- All components receive new state via context
- Focus returns to the original trigger
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.
<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.
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.
<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.
<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
.
<Dialog.Content
onEscapeKeyDown={(event) => {
// Custom escape behavior
if (hasUnsavedChanges) {
event.preventDefault()
showConfirmDialog()
}
}}
onPointerDownOutside={(event) => {
// Custom click-outside behavior
if (isFormDirty) {
event.preventDefault()
}
}}
>
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:
- shadcn/ui components follow the same structure – they're styled versions of primitives.
- You can customize any part – understanding the anatomy shows you what's possible.
- You can debug issues – knowing how primitives work helps troubleshoot problems.
- 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.
Was this helpful?