Core Concepts
Advanced mental model for duck-primitives: composition, state ownership, focus safety, dismissal orchestration, and animation lifecycle.
These patterns are the contract design-system wrappers should preserve.
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><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)
}}
><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()
}}
/><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><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;
}[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.