Core Concepts
Advanced mental model for duck-primitives: composition, state ownership, focus safety, dismissal orchestration, and animation lifecycle.
1) Composition via asChild
asChild replaces the primitive's default DOM node with the child element, keeping
the primitive's behavior and semantics.
<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 open/close depends on async work, validation, permissions, or routing.
<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 need reliable focus behavior:
- Initial focus on open.
- Tab loop containment.
- Focus restoration on close.
Dialog-like primitives handle this internally. If you intercept focus events
(onOpenAutoFocus, onCloseAutoFocus), keep the accessibility intent.
<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
Cancel dismissal with event.preventDefault().
Use this for guarded flows: unsaved changes, in-flight requests, 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
In practice: 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
This is a layout API, not a 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>
When wrapper components fail in nested cases, the root cause 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;
}
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
When behavior looks wrong, check in 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.