#4128·primitives

Popover: `PopoverContent` hardcodes `loop: true`, so Tab cannot leave a non-modal popover

Author: paulk-lfCreated Aug 22, 2026Updated Sep 10, 2026

Bug

PopoverContent passes loop: true to its FocusScope unconditionally, so Tab wraps at the panel's edges even when modal={false}. A keyboard user cannot Tab out of a non-modal popover.

This is not a focus trap — nothing outside is hidden or made inert, a click or a programmatic focus move leaves freely, and Escape closes and returns focus to the trigger. But Tab, the primary way a keyboard user moves forward, cannot leave the panel.

Where

packages/react/popover/src/popover.tsxPopoverContentImpl renders:

javascript
<FocusScope
  asChild
  loop
  trapped={trapFocus}
  onMountAutoFocus={onOpenAutoFocus}
  onUnmountAutoFocus={onCloseAutoFocus}
>

trapped follows modal, but loop is hardcoded.

packages/react/focus-scope/src/focus-scope.tsx then gates its Tab handler on either flag:

javascript
if (!loop && !trapped) return;
...
if (loop) focus(first, { select: true });   // wrapping forward
if (loop) focus(last, { select: true });    // wrapping backward

So loop: true keeps the wrapping behaviour alive in the non-modal case, and PopoverContent exposes no prop to turn it off.

Verified in @radix-ui/[email protected].

Reproduction

javascript
<Popover.Root modal={false}>
  <Popover.Trigger>Open</Popover.Trigger>
  <Popover.Portal>
    <Popover.Content>
      <input aria-label="First" />
      <button>Cancel</button>
      <button>Save</button>
    </Popover.Content>
  </Popover.Portal>
</Popover.Root>
  1. Open the popover with the keyboard.
  2. Tab to Save, the last control in the panel.
  3. Tab again.

Expected: focus moves to the next tabbable element in the document, since the popover is non-modal and the rest of the page is explicitly still available.

Actual: focus returns to First, with its text selected, and never leaves the panel.

Why it matters

The documented purpose of modal={false} is that the page behind the popover stays usable and stays visible to assistive technology. That holds for pointer users and for screen-reader users navigating by virtual cursor. It does not hold for keyboard users: for them the non-modal popover behaves exactly like a modal one, minus the scrim.

It also makes the two dismissal routes asymmetric. Clicking outside moves focus to wherever the click landed; there is no keyboard equivalent that continues forward through the page.

Suggested fix

Either default loop to trapFocus so it follows modality:

javascript
<FocusScope asChild loop={trapFocus} trapped={trapFocus} …>

or expose it on PopoverContent so a consumer can opt out, the way trapFocus already is internally.

The first matches the semantics people expect from modal={false} and needs no API surface. The second is safer for anyone relying on today's behaviour.

Workaround, and why it is unattractive

Intercepting Tab on PopoverContent and moving focus manually works, but it means re-implementing the tab-order walk that FocusScope already does, and Radix composes the consumer's onKeyDown before FocusScope's without checking defaultPrevented — so the consumer handler has to perform the move itself rather than just suppress the default. We removed exactly that workaround when migrating onto this primitive and would rather not reintroduce it.

Environment

  • @radix-ui/react-popover 1.1.23
  • React 19.2
  • Chrome 141, macOS