Skip to main content

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

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:

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

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:

  • 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/primitives
bun add @gentleduck/primitives