building accessible uis with duck primitives
How @gentleduck/primitives handles focus trapping, keyboard navigation, ARIA attributes, and screen reader support so you can focus on design.
Accessibility Is Not Optional
Keyboard navigation, screen reader support, focus management, and ARIA are requirements, not extras. Implementing them by hand is tedious and easy to get wrong.
@gentleduck/primitives handles the hard parts. You handle design and business logic.
What Primitives Handle For You
Focus Management
When a dialog opens, focus must move into it. When it closes, focus must return to the trigger. Tab must cycle within the dialog and not escape to the page behind it.
Primitives handle all of this:
- Focus trapping — Tab and Shift+Tab cycle inside modal surfaces.
- Focus restoration — focus returns to the trigger on close.
- Initial focus — configurable initial target.
- Focus scope — nested focus contexts for complex UIs.
Keyboard Navigation
Every primitive supports full keyboard interaction:
| Primitive | Keyboard Support |
|---|---|
| Dialog | Escape to close, Tab to cycle focus |
| Dropdown Menu | Arrow keys to navigate, Enter to select, Escape to close |
| Select | Arrow keys to browse, Enter to select, type-ahead search |
| Slider | Arrow keys for value, Home/End for range |
| Tabs | Arrow keys to switch, focus follows selection |
| Navigation Menu | Arrow keys between items, Enter to activate |
ARIA Attributes
Primitives set the correct ARIA roles, states, and properties automatically:
// You write this:
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Settings</Dialog.Title>
<Dialog.Description>Update your preferences.</Dialog.Description>
</Dialog.Content>
</Dialog.Root>
// Primitives render this:
<button aria-haspopup="dialog" aria-expanded="true">Open</button>
<div role="dialog" aria-modal="true" aria-labelledby="..." aria-describedby="...">
<h2 id="...">Settings</h2>
<p id="...">Update your preferences.</p>
</div>// You write this:
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Settings</Dialog.Title>
<Dialog.Description>Update your preferences.</Dialog.Description>
</Dialog.Content>
</Dialog.Root>
// Primitives render this:
<button aria-haspopup="dialog" aria-expanded="true">Open</button>
<div role="dialog" aria-modal="true" aria-labelledby="..." aria-describedby="...">
<h2 id="...">Settings</h2>
<p id="...">Update your preferences.</p>
</div>The Primitives Catalog
| Primitive | Accessibility Features |
|---|---|
| Dialog | Focus trap, Escape close, aria-modal, aria-labelledby |
| Alert Dialog | Same as Dialog + requires explicit action (no click-outside dismiss) |
| Drawer | Focus trap, drag-to-dismiss, aria-modal |
| Sheet | Focus trap, Escape close, edge-slide animation |
| Popover | Focus management, click-outside dismiss, aria-expanded |
| Tooltip | Hover + focus trigger, aria-describedby, delay management |
| Hover Card | Hover intent, dismiss on pointer leave |
| Dropdown Menu | Roving focus, type-ahead, nested submenus, role="menu" |
| Context Menu | Right-click trigger, same keyboard nav as Dropdown |
| Menubar | Horizontal menu bar, arrow key navigation between menus |
| Select | Listbox pattern, type-ahead, aria-selected |
| Slider | aria-valuemin/max/now, keyboard step control |
| Navigation Menu | Arrow navigation, flyout management |
| Progress | role="progressbar", aria-valuenow |
| Input OTP | Individual digit inputs with auto-advance |
Unstyled by Design
Primitives ship with zero CSS. They provide behavior, not appearance:
- Nothing to override.
- No specificity battles.
- The design system is yours.
- Works with Tailwind, CSS modules, vanilla CSS, or anything else.
Pair with @gentleduck/variants for type-safe styling and @gentleduck/motion for animations.
Building a Custom Component
The pattern for an accessible dialog built from primitives:
import * as Dialog from '@gentleduck/primitives/dialog'
import { cva } from '@gentleduck/variants'
import { cn } from '@gentleduck/libs'
const overlayStyles = cva('fixed inset-0 bg-black/50 animate-in fade-in')
const contentStyles = cva('fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-lg animate-in fade-in zoom-in-95')
export function MyDialog({ children, trigger, title, description }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={cn(overlayStyles())} />
<Dialog.Content className={cn(contentStyles())}>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}import * as Dialog from '@gentleduck/primitives/dialog'
import { cva } from '@gentleduck/variants'
import { cn } from '@gentleduck/libs'
const overlayStyles = cva('fixed inset-0 bg-black/50 animate-in fade-in')
const contentStyles = cva('fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-lg animate-in fade-in zoom-in-95')
export function MyDialog({ children, trigger, title, description }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={cn(overlayStyles())} />
<Dialog.Content className={cn(contentStyles())}>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}Focus trapping, Escape handling, ARIA attributes, and click-outside dismissal are all handled.
Getting Started
bun add @gentleduck/primitivesbun add @gentleduck/primitives