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 attributes are not extras — they are requirements. But implementing them correctly is tedious, error-prone, and easy to get wrong.

@gentleduck/primitives handles the hard parts so you can focus on design and business logic.


What Primitives Handle For You

Loading diagram...

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 automatically:

  • Focus trapping — Tab and Shift+Tab cycle within modal surfaces
  • Focus restoration — Focus returns to the trigger element on close
  • Initial focus — Configurable initial focus target
  • Focus scope — Nested focus contexts for complex UIs

Keyboard Navigation

Every primitive supports full keyboard interaction:

PrimitiveKeyboard Support
DialogEscape to close, Tab to cycle focus
Dropdown MenuArrow keys to navigate, Enter to select, Escape to close
SelectArrow keys to browse, Enter to select, type-ahead search
SliderArrow keys for value, Home/End for range
TabsArrow keys to switch, focus follows selection
Navigation MenuArrow 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

PrimitiveAccessibility Features
DialogFocus trap, Escape close, aria-modal, aria-labelledby
Alert DialogSame as Dialog + requires explicit action (no click-outside dismiss)
DrawerFocus trap, drag-to-dismiss, aria-modal
SheetFocus trap, Escape close, edge-slide animation
PopoverFocus management, click-outside dismiss, aria-expanded
TooltipHover + focus trigger, aria-describedby, delay management
Hover CardHover intent, dismiss on pointer leave
Dropdown MenuRoving focus, type-ahead, nested submenus, role="menu"
Context MenuRight-click trigger, same keyboard nav as Dropdown
MenubarHorizontal menu bar, arrow key navigation between menus
SelectListbox pattern, type-ahead, aria-selected
Slideraria-valuemin/max/now, keyboard step control
Navigation MenuArrow navigation, flyout management
Progressrole="progressbar", aria-valuenow
Input OTPIndividual digit inputs with auto-advance

Unstyled by Design

Primitives ship with zero CSS. They provide behavior, not appearance. This means:

  • No fighting with pre-built styles
  • No CSS specificity battles
  • Full control over your design system
  • Works with Tailwind, CSS modules, vanilla CSS, or any styling approach

Pair with @gentleduck/variants for type-safe styling and @gentleduck/motion for animations.


Building a Custom Component

Here is the pattern for building an accessible dialog 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 all work automatically.


Getting Started

bun add @gentleduck/primitives
bun add @gentleduck/primitives