Core Concepts
Advanced mental model for duck-primitives: composition, state ownership, focus safety, dismissal orchestration, and animation lifecycle.
If primitives feel "magic", this page removes the magic. These patterns are the contract your design-system wrappers should preserve.
1) Composition via asChild
asChild replaces the primitive's default DOM node with your child element, while preserving primitive behavior and semantics.
<Dialog.Trigger asChild>
<a href="#">Open</a>
</Dialog.Trigger><Dialog.Trigger asChild>
<a href="#">Open</a>
</Dialog.Trigger>Merge behavior model:
- Event handlers are composed.
refis composed.classNameandstyleare merged.- ARIA/data attributes are forwarded.
Rules:
- Provide exactly one child.
- Child components must forward refs.
- Do not swallow incoming props in your custom components.
2) State ownership: controlled vs uncontrolled
Every stateful primitive supports both patterns:
- Uncontrolled for local behavior (
defaultOpen,defaultValue). - Controlled for business workflows (
open+onOpenChange,value+onValueChange).
Use controlled mode when close/open decisions depend on async work, validation, permissions, or routing.
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (canDismiss(next)) setOpen(next)
}}
><Dialog.Root
open={open}
onOpenChange={(next) => {
if (canDismiss(next)) setOpen(next)
}}
>Anti-pattern: mixing controlled and uncontrolled over component lifetime.
3) Focus safety with Focus Scope
Modal surfaces depend on reliable focus behavior:
- Initial focus on open.
- Tab loop containment.
- Focus restoration on close.
Dialog-like primitives implement this internally. If you intercept focus events (onOpenAutoFocus, onCloseAutoFocus), preserve accessibility intent.
<Dialog.Content
onOpenAutoFocus={(event) => {
event.preventDefault()
primaryInputRef.current?.focus()
}}
/><Dialog.Content
onOpenAutoFocus={(event) => {
event.preventDefault()
primaryInputRef.current?.focus()
}}
/>4) Dismiss orchestration with Dismissable Layer
Dismiss behavior is event-driven, not hardcoded. Key hooks:
onPointerDownOutsideonFocusOutsideonInteractOutsideonEscapeKeyDownonDismiss
You can cancel dismissal with event.preventDefault().
Use this for guarded flows (unsaved changes, in-progress request, multi-step confirmation).
5) Mount lifecycle with Presence
Presence is how primitives support CSS exit animations without race conditions.
State model:
mountedunmountSuspended(exit animation in progress)unmounted
Practical implication: style enter/exit through data-state and CSS keyframes. Use forceMount when an external animation library controls DOM presence.
6) Positioning contract with Popper
Floating primitives expose a stable placement API:
side,alignsideOffset,alignOffsetavoidCollisions,collisionPadding,collisionBoundary
Treat this as layout API, not styling API. Keep visual concerns in classes/tokens.
7) Context scoping and nested primitives
Primitives use scoped contexts so nested instances remain independent.
<Dialog.Root>
<Dialog.Content>
<Dialog.Root>
<Dialog.Content>Nested dialog</Dialog.Content>
</Dialog.Root>
</Dialog.Content>
</Dialog.Root><Dialog.Root>
<Dialog.Content>
<Dialog.Root>
<Dialog.Content>Nested dialog</Dialog.Content>
</Dialog.Root>
</Dialog.Content>
</Dialog.Root>When wrapper components fail in nested cases, the root issue is usually prop swallowing or broken ref forwarding, not primitive context.
8) Data attributes are your styling API
Use data attributes for visual states and ARIA attributes for assistive semantics.
data-state="open" | "closed"data-disableddata-highlighteddata-side,data-align(floating content)
[data-state='open'] {
animation: fadeIn 160ms ease;
}
[data-state='closed'] {
animation: fadeOut 120ms ease;
}[data-state='open'] {
animation: fadeIn 160ms ease;
}
[data-state='closed'] {
animation: fadeOut 120ms ease;
}9) Wrapper design rules for design systems
- Re-export Root directly where possible.
- Wrap visual parts with
forwardRef. - Preserve primitive props (
...props) andclassNameextensibility. - Set sensible defaults (
sideOffset, animation classes, padding). - Keep accessibility defaults non-optional (title requirements, labels).
10) Debugging checklist
If behavior feels wrong, check this order:
- Is
asChildchild a single ref-forwarding element? - Are you preventing dismissal/focus events unintentionally?
- Did you move interactive nodes outside the expected layer/content?
- Is controlled state updating synchronously?
- Are exit animations defined for closed state when using Presence?
Next
- Course — Structured progression with labs.
- Guides — Focused topics: styling, animation, composition, accessibility.
- API Reference — Per-primitive props and events.