Getting Started
Set up gentleduck/primitives with production defaults: accessibility, animation, direction, and state control.
This guide is optimized for real projects, not toy demos. You will install primitives, wire global direction, build a controlled dialog, and validate behavior with a practical QA checklist.
Install
npm install @gentleduck/primitives
npm install @gentleduck/primitives
Requirements:
- React
19+ - TypeScript optional (types are bundled)
Set document direction once
If your product may support RTL later, set direction now.
import { DirectionProvider } from '@gentleduck/primitives/direction'
export function Providers({ children }: { children: React.ReactNode }) {
return <DirectionProvider dir="ltr">{children}</DirectionProvider>
}import { DirectionProvider } from '@gentleduck/primitives/direction'
export function Providers({ children }: { children: React.ReactNode }) {
return <DirectionProvider dir="ltr">{children}</DirectionProvider>
}Also set document semantics:
<html dir="ltr" lang="en"><html dir="ltr" lang="en">Use dir="rtl" at provider or primitive level where needed.
First production-ready Dialog
import * as React from 'react'
import * as Dialog from '@gentleduck/primitives/dialog'
export function DeleteProjectDialog() {
const [open, setOpen] = React.useState(false)
const [submitting, setSubmitting] = React.useState(false)
async function onDelete() {
try {
setSubmitting(true)
// await deleteProject()
setOpen(false)
} finally {
setSubmitting(false)
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger className="rounded-md border px-3 py-2 text-sm">
Delete project
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out" />
<Dialog.Content
className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-background p-5 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out"
onEscapeKeyDown={(event) => {
if (submitting) event.preventDefault()
}}
onInteractOutside={(event) => {
if (submitting) event.preventDefault()
}}
>
<Dialog.Title className="text-lg font-semibold">Delete project?</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-muted-foreground">
This action removes all environments and cannot be undone.
</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close asChild>
<button className="rounded-md border px-3 py-2 text-sm" disabled={submitting}>
Cancel
</button>
</Dialog.Close>
<button
className="rounded-md bg-red-600 px-3 py-2 text-sm text-white"
disabled={submitting}
onClick={onDelete}
>
{submitting ? 'Deleting...' : 'Delete'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}import * as React from 'react'
import * as Dialog from '@gentleduck/primitives/dialog'
export function DeleteProjectDialog() {
const [open, setOpen] = React.useState(false)
const [submitting, setSubmitting] = React.useState(false)
async function onDelete() {
try {
setSubmitting(true)
// await deleteProject()
setOpen(false)
} finally {
setSubmitting(false)
}
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger className="rounded-md border px-3 py-2 text-sm">
Delete project
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out" />
<Dialog.Content
className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-background p-5 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out"
onEscapeKeyDown={(event) => {
if (submitting) event.preventDefault()
}}
onInteractOutside={(event) => {
if (submitting) event.preventDefault()
}}
>
<Dialog.Title className="text-lg font-semibold">Delete project?</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-muted-foreground">
This action removes all environments and cannot be undone.
</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close asChild>
<button className="rounded-md border px-3 py-2 text-sm" disabled={submitting}>
Cancel
</button>
</Dialog.Close>
<button
className="rounded-md bg-red-600 px-3 py-2 text-sm text-white"
disabled={submitting}
onClick={onDelete}
>
{submitting ? 'Deleting...' : 'Delete'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}What this gives you immediately:
- Focus trapping and restoration.
- Escape + outside interaction dismissal control.
- Correct dialog ARIA semantics.
- State-driven animation hooks via
data-state.
Import strategy
Use namespace imports to keep JSX readable and avoid naming collisions:
import * as Dialog from '@gentleduck/primitives/dialog'
import * as Popover from '@gentleduck/primitives/popover'
import * as Tooltip from '@gentleduck/primitives/tooltip'import * as Dialog from '@gentleduck/primitives/dialog'
import * as Popover from '@gentleduck/primitives/popover'
import * as Tooltip from '@gentleduck/primitives/tooltip'Controlled vs uncontrolled rule
- Use uncontrolled (
defaultOpen) for simple local UI. - Use controlled (
open+onOpenChange) for workflows, async actions, analytics, or URL-driven state.
If you need business rules around close behavior, controlled mode should be your default.
Minimal QA before merge
- Keyboard-only: tab into trigger, open, tab cycle, close with Escape, confirm focus restore.
- Screen reader: confirm title and description are announced.
- Nested overlays: confirm outside click behavior is correct.
- Reduced motion: ensure animations are disabled when
prefers-reduced-motion: reduce. - RTL pass: verify placement/alignment for at least one floating primitive.
Next pages
- Core Concepts — Deep mechanics:
asChild, Presence, Dismissable Layer, context scoping. - Course — Structured lessons with labs and production patterns.
- API Reference — Full prop and event reference.