Popover: `PopoverContent` hardcodes `loop: true`, so Tab cannot leave a non-modal popover
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.tsx — PopoverContentImpl renders:
<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:
if (!loop && !trapped) return;
...
if (loop) focus(first, { select: true }); // wrapping forward
if (loop) focus(last, { select: true }); // wrapping backwardSo 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
<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>- Open the popover with the keyboard.
- Tab to
Save, the last control in the panel. - 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:
<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-popover1.1.23- React 19.2
- Chrome 141, macOS
Source: radix-ui/primitives