# Don't Mix Primitive Systems Within One Surface

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

> NEVER: Mix primitive systems (Radix + React Aria) within the same interaction surface. Focus management conflicts cause bugs. Pick one system per component.

Never mix primitive libraries within the same interaction surface — a dialog whose trigger, content, and focus trap come from different systems

The scope qualifier is the whole rule. Two primitive systems inside one interaction surface fight over the same state: focus trapping, portalling, outside-click dismissal, and Escape handling get claimed twice, so focus lands in the wrong place and Escape closes the wrong layer. Across separate surfaces this is not an error — a codebase mid-migration may legitimately serve Radix in one surface and Base UI in another, and forcing a big-bang swap is worse than living with the split. Converge surface by surface.

## Bad — do not do this

`interactions-ibelick-no-primitive-mixing-bad`

```tsx
import { useState } from 'react';
import { ChevronDown, CornerDownLeft, RotateCw, AlertTriangle } from 'lucide-react';

type FocusTarget = 'trigger' | 'dialog' | 'menu' | 'lost';

const focusLabel: Record<FocusTarget, string> = {
  trigger: 'Actions trigger',
  dialog: 'Dialog',
  menu: 'Menu',
  lost: 'lost (body)',
};

export function IbelickNoPrimitiveMixingBad() {
  const [dialogOpen, setDialogOpen] = useState(true);
  const [menuOpen, setMenuOpen] = useState(true);
  const [focus, setFocus] = useState<FocusTarget>('menu');
  const [note, setNote] = useState('Two systems both listen for Esc on one surface — press Esc.');

  const esc = () => {
    // Both Radix and Headless UI have registered an Escape handler on the same
    // surface, so a single Esc fires both: the menu AND the dialog close.
    setMenuOpen(false);
    setDialogOpen(false);
    setFocus('lost');
    setNote('One Esc, two listeners: the menu AND the dialog closed, and focus fell back to the body.');
  };

  const reset = () => {
    setDialogOpen(true);
    setMenuOpen(true);
    setFocus('menu');
    setNote('Two systems both listen for Esc on one surface — press Esc.');
  };

  return (
    <div className="w-full max-w-sm space-y-3">
      <div className="rounded-lg border border-border bg-card p-4">
        <div className="relative flex h-40 items-center justify-center rounded-md bg-muted/50 p-3">
          {dialogOpen ? (
            <div className="w-full rounded-lg border border-destructive/50 bg-card p-3 shadow-sm">
              <div className="mb-2 flex items-center justify-between">
                <span className="text-sm font-medium">Dialog</span>
                <span className="rounded-full bg-destructive/15 px-2 py-0.5 text-[10px] font-medium text-destructive">
                  Radix · owns Esc
                </span>
              </div>
              <div className="relative">
                <button className="flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1 text-xs">
                  Actions
                  <ChevronDown className="h-3 w-3" />
                </button>
                {menuOpen && (
                  <div className="mt-2 rounded-md border border-destructive/40 bg-card p-1.5 shadow-md">
                    <div className="mb-1 flex items-center justify-between px-1">
                      <span className="text-[11px] text-muted-foreground">Menu</span>
                      <span className="rounded-full bg-destructive/15 px-1.5 py-0.5 text-[10px] font-medium text-destructive">
                        Headless UI · also owns Esc
                      </span>
                    </div>
                    {['Rename', 'Duplicate', 'Delete'].map((item) => (
                      <div key={item} className="rounded px-2 py-1 text-xs hover:bg-muted">
                        {item}
                      </div>
                    ))}
                  </div>
                )}
              </div>
            </div>
          ) : (
            <div className="flex items-center gap-2 text-xs text-destructive">
              <AlertTriangle className="h-4 w-4" />
              Both layers gone, focus lost.
            </div>
          )}
        </div>

        <div className="mt-3 flex items-center justify-between">
          <div className="flex gap-2">
            <button
              onClick={esc}
              disabled={!dialogOpen && !menuOpen}
              className="flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs transition-colors hover:bg-muted disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              <CornerDownLeft className="h-3 w-3 rotate-90" />
              Press Esc
            </button>
            <button
              onClick={reset}
              className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              <RotateCw className="h-3 w-3" />
              Reset
            </button>
          </div>
          <span className="text-xs text-muted-foreground">
            Focus: <span className="font-medium text-foreground">{focusLabel[focus]}</span>
          </span>
        </div>
      </div>

      <p className="text-xs text-destructive">{note}</p>
    </div>
  );
}
```

## Good — do this

`interactions-ibelick-no-primitive-mixing-good`

```tsx
import { useState } from 'react';
import { ChevronDown, CornerDownLeft, RotateCw } from 'lucide-react';

type FocusTarget = 'trigger' | 'dialog' | 'menu' | 'lost';

const focusLabel: Record<FocusTarget, string> = {
  trigger: 'Actions trigger',
  dialog: 'Dialog',
  menu: 'Menu',
  lost: 'lost (body)',
};

export function IbelickNoPrimitiveMixingGood() {
  const [dialogOpen, setDialogOpen] = useState(true);
  const [menuOpen, setMenuOpen] = useState(true);
  const [focus, setFocus] = useState<FocusTarget>('menu');
  const [note, setNote] = useState('One owner per layer — press Esc to peel them back.');

  const esc = () => {
    if (menuOpen) {
      setMenuOpen(false);
      setFocus('dialog');
      setNote('Esc closed the menu (top layer). The dialog stays open.');
    } else if (dialogOpen) {
      setDialogOpen(false);
      setFocus('trigger');
      setNote('Esc closed the dialog. Focus returned to the Actions trigger.');
    }
  };

  const reset = () => {
    setDialogOpen(true);
    setMenuOpen(true);
    setFocus('menu');
    setNote('One owner per layer — press Esc to peel them back.');
  };

  return (
    <div className="w-full max-w-sm space-y-3">
      <div className="rounded-lg border border-border bg-card p-4">
        <div className="relative flex h-40 items-center justify-center rounded-md bg-muted/50 p-3">
          {dialogOpen ? (
            <div className="w-full rounded-lg border border-primary/60 bg-card p-3 shadow-sm">
              <div className="mb-2 flex items-center justify-between">
                <span className="text-sm font-medium">Dialog</span>
                <span className="rounded-full bg-success/15 px-2 py-0.5 text-[10px] font-medium text-success">
                  Radix · owns focus + Esc
                </span>
              </div>
              <div className="relative">
                <button className="flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1 text-xs">
                  Actions
                  <ChevronDown className="h-3 w-3" />
                </button>
                {menuOpen && (
                  <div className="mt-2 rounded-md border border-primary/40 bg-card p-1.5 shadow-md">
                    <div className="mb-1 flex items-center justify-between px-1">
                      <span className="text-[11px] text-muted-foreground">Menu</span>
                      <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
                        nested layer
                      </span>
                    </div>
                    {['Rename', 'Duplicate', 'Delete'].map((item) => (
                      <div key={item} className="rounded px-2 py-1 text-xs hover:bg-muted">
                        {item}
                      </div>
                    ))}
                  </div>
                )}
              </div>
            </div>
          ) : (
            <button
              onClick={reset}
              className="flex items-center gap-1 rounded-md border border-border bg-background px-2.5 py-1 text-xs"
            >
              Actions
              <ChevronDown className="h-3 w-3" />
            </button>
          )}
        </div>

        <div className="mt-3 flex items-center justify-between">
          <div className="flex gap-2">
            <button
              onClick={esc}
              disabled={!dialogOpen}
              className="flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs transition-colors hover:bg-muted disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              <CornerDownLeft className="h-3 w-3 rotate-90" />
              Press Esc
            </button>
            <button
              onClick={reset}
              className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            >
              <RotateCw className="h-3 w-3" />
              Reset
            </button>
          </div>
          <span className="text-xs text-muted-foreground">
            Focus: <span className="font-medium text-foreground">{focusLabel[focus]}</span>
          </span>
        </div>
      </div>

      <p className="text-xs text-success">{note}</p>
    </div>
  );
}
```

## References

- [ibelick/ui-skills — baseline-ui](https://github.com/ibelick/ui-skills)
