Skip to content

popover: Add loop prop to non-modal Popover.Content - #4140

Open
jafari1995 wants to merge 2 commits into
radix-ui:mainfrom
jafari1995:fix/popover-nonmodal-tab
Open

jafari1995 wants to merge 2 commits into
radix-ui:mainfrom
jafari1995:fix/popover-nonmodal-tab

Conversation

@jafari1995

@jafari1995 jafari1995 commented Sep 10, 2026 •

Copy link
Copy Markdown

Summary

Non-modal popovers always loop Tab because FocusScope always gets loop. Changing that by default is breaking, so this adds a loop prop to Popover.Content instead (default true, same as today). With loop={false}, Tab from the last control moves focus out of the popover. Modal popovers ignore it and keep looping.

Fixes #4128

Test plan

  • Non-modal popover still loops by default
  • loop={false} lets Tab leave a non-modal popover
  • Modal popover keeps looping with loop={false}
  • Added a "Non modal (no loop)" case to the Modality story

@changeset-bot

changeset-bot Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: baa3400

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@radix-ui/react-popover Minor
radix-ui Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@jafari1995

Copy link
Copy Markdown
Author

Checks still haven't run on this branch. It's up to date with main, and the non-modal Tab test passes locally. Could someone approve the workflows?

@chaance chaance changed the title fix(popover): don't loop focus when modal is false popover: Don't loop focus when modal is false Oct 13, 2026

@chaance chaance left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this is probably the correct behavior, but it's also a big enough behavioral change that I'd consider it breaking and something we should ship in a major release. I think for now we should probably expose loop as a prop that consumers can override (for non-modal popovers only; no-op for modal since focus is trapped). In v2 we can flip the default to false.

@chiragjangiir

Copy link
Copy Markdown

Supporting the direction of exposing loop as a consumer prop, with one constraint worth pinning down from focus-scope.tsx:

In handleKeyDown (packages/react/focus-scope/src/focus-scope.tsx, main):

if (!loop && !trapped) return;            // L210
...
if (!event.shiftKey && focusedElement === last) {
  event.preventDefault();                  // L232 — runs before the `loop` check
  if (loop) focus(first, { select: true });
}

The preventDefault() runs before the if (loop) guard, so on the trailing edge with loop=false, Tab is still swallowed even though no wrap happens — focus stays on Save. That's actually the behavior we want here: without it, Tab from the last control would fire the browser's default focus walk, which cannot know that focus just entered the scope through onMountAutoFocus, and would move focus backwards to content that precedes the trigger (matching what the issue reporter saw pre-fix).

So for the non-modal case (trapped=false, loop=false): Tab works normally within the panel, and Tab on the trailing edge stops at the edge instead of escaping forward. That's a deviation from plain document flow and worth documenting in the PR as the exact expected contract when the prop lands. It also means the exposed prop should stay render-transparent: Select/Menu-style consumers that rely on arrow-key roving focus inside closed scopes are unaffected (focusScope.paused short-circuits at L211), but anything relying on Tab-wrapping in a non-trapped scope today would need opt-in.

Two suggestions for the API:

  1. Document the "Tab stops at the last control" edge explicitly in the changeset/description, since it differs from trapFocus=false + native Tab expectations.
  2. Consider exposing it only on Popover.Content (not PopoverContentPrimitive) so the escape hatch doesn't leak into modal-only contexts where it's a no-op anyway.

@jafari1995 jafari1995 changed the title popover: Don't loop focus when modal is false popover: Add loop prop to non-modal Popover.Content Oct 24, 2026
@jafari1995

Copy link
Copy Markdown
Author

Reworked it so nothing changes by default. Popover.Content now takes a loop prop (default true) for non-modal popovers, and passing loop={false} lets Tab leave the popover. It has no effect on modal ones.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants