# Use Accessible Primitives

**MUST** · **ID:** `interactions-ibelick-accessible-primitives` · **Category:** interactions
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-ibelick-accessible-primitives

> MUST: Use accessible component primitives (Base UI, React Aria, Radix) for keyboard/focus behavior. These handle ARIA, focus management, and keyboard navigation correctly.

Anything with keyboard or focus behavior should be built on an accessible primitive (Base UI, React Aria, Radix)

Building accessible components from scratch is genuinely hard: keyboard navigation, focus trapping, ARIA wiring, and screen reader announcements all have to be right at once. Primitive libraries have already paid that cost. Note the trigger is narrow — "anything with keyboard or focus behavior." A static card does not need a primitive; a menu, dialog, combobox, or tab set does.

## Bad — do not do this

`interactions-ibelick-accessible-primitives-bad`

```tsx
import { useState } from 'react';

export function IbelickAccessiblePrimitivesBad() {
  const [isOpen, setIsOpen] = useState(false);
  const [modalOpen, setModalOpen] = useState(false);

  return (
    <div className="space-y-4">
      {/* BAD: Hand-rolled dropdown without accessibility */}
      <div className="relative">
        <button
          onClick={() => setIsOpen(!isOpen)}
          // BAD: Missing aria-expanded, aria-haspopup
          className="px-4 py-2 bg-primary text-primary-foreground rounded-lg"
        >
          Open Menu
        </button>
        {isOpen && (
          // BAD: No role="menu", no focus trap, no keyboard navigation
          // BAD: Clicking outside doesn't close, Escape doesn't close
          <div className="absolute top-full mt-2 w-48 bg-popover border rounded-lg shadow-lg p-2 z-10">
            {/* BAD: divs instead of buttons, no role="menuitem" */}
            {/* BAD: Can't navigate with arrow keys */}
            {/* BAD: No focus management - can't tab to these */}
            <div
              className="px-3 py-2 hover:bg-muted rounded cursor-pointer"
              onClick={() => setIsOpen(false)}
            >
              Option 1
            </div>
            <div
              className="px-3 py-2 hover:bg-muted rounded cursor-pointer"
              onClick={() => setIsOpen(false)}
            >
              Option 2
            </div>
            <div
              className="px-3 py-2 hover:bg-muted rounded cursor-pointer"
              onClick={() => setIsOpen(false)}
            >
              Option 3
            </div>
          </div>
        )}
      </div>

      {/* BAD: Hand-rolled modal without accessibility */}
      <button
        onClick={() => setModalOpen(true)}
        className="px-4 py-2 bg-muted rounded-lg"
      >
        Open Modal
      </button>
      {modalOpen && (
        // BAD: No focus trap - can tab to elements behind modal
        // BAD: No role="dialog", no aria-modal
        // BAD: No return focus to trigger on close
        // BAD: Escape doesn't close
        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
          <div className="bg-popover p-6 rounded-lg w-80">
            <h3 className="font-medium mb-4">Modal Title</h3>
            <p className="text-sm text-muted-foreground mb-4">
              Try tabbing - focus escapes this modal!
            </p>
            <input
              type="text"
              placeholder="Type here..."
              className="w-full px-3 py-2 border rounded mb-4"
            />
            <button
              onClick={() => setModalOpen(false)}
              className="px-4 py-2 bg-primary text-primary-foreground rounded"
            >
              Close
            </button>
          </div>
        </div>
      )}

      <p className="text-xs text-destructive">
        Hand-rolled components: no keyboard nav, no focus trap, no ARIA, no Escape to close
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-ibelick-accessible-primitives-good`

```tsx
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

const triggerClass =
  'px-4 py-2 rounded-lg text-sm transition-colors duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring';

export function IbelickAccessiblePrimitivesGood() {
  return (
    <div className="space-y-4">
      <p className="text-sm text-muted-foreground">
        Open either one, then try Escape, Tab and clicking outside.
      </p>

      {/* Menu: a real Radix Popover. */}
      <Popover>
        <PopoverTrigger className={`${triggerClass} bg-primary text-primary-foreground hover:bg-primary/90`}>
          Open menu
        </PopoverTrigger>
        <PopoverContent align="start" className="w-48 p-2">
          {['Rename', 'Duplicate', 'Delete'].map((item) => (
            <button
              key={item}
              className="w-full text-left px-3 py-2 rounded text-sm hover:bg-muted focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              {item}
            </button>
          ))}
        </PopoverContent>
      </Popover>

      {/* Modal: a real Radix Dialog, which traps focus and returns it on close. */}
      <Dialog>
        <DialogTrigger className={`${triggerClass} bg-muted hover:bg-muted/80`}>Open modal</DialogTrigger>
        <DialogContent className="w-80">
          <DialogHeader>
            <DialogTitle>Modal title</DialogTitle>
            <DialogDescription>
              Tab around: focus stays inside. Escape closes it and returns focus to the trigger.
            </DialogDescription>
          </DialogHeader>
          <input
            type="text"
            placeholder="Type here..."
            className="w-full px-3 py-2 bg-background border border-border rounded focus:outline-hidden focus:ring-2 focus:ring-ring"
          />
          <DialogClose className={`${triggerClass} bg-primary text-primary-foreground hover:bg-primary/90`}>
            Close
          </DialogClose>
        </DialogContent>
      </Dialog>

      <p className="text-xs text-success">
        Real primitives: the menu closes on Escape and click-outside, and the modal traps focus and hands it back to
        the trigger. None of that behaviour is hand-written
      </p>
    </div>
  );
}
```

## References

- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
- [Base UI](https://base-ui.com/react/components)
- [React Aria](https://react-spectrum.adobe.com/react-aria/)
- [Radix UI Primitives](https://www.radix-ui.com/primitives)
