diff --git a/.changeset/focus-region-chorded-tab.md b/.changeset/focus-region-chorded-tab.md
new file mode 100644
index 0000000000000..8e14756439bb6
--- /dev/null
+++ b/.changeset/focus-region-chorded-tab.md
@@ -0,0 +1,6 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] Dialog, CommandPalette, BottomSheet, Lightbox, Drawer, MobileNav, Popover: a Tab pressed with Ctrl or Meta on the first or last control of a modal no longer moves focus to the other end, and the modal no longer cancels the press. A page or a host app that handles Ctrl+Tab or Meta+Tab itself, for example to switch its own tabs, now gets the press on every control, as it already did between the first and the last. Alt+Tab still wraps, because Safari moves focus to each item on Option+Tab.
+@cixzhang @vjeux
diff --git a/docs/specs/components/Dialog/Dialog.spec.md b/docs/specs/components/Dialog/Dialog.spec.md
index 3d53b08bdf9ca..ee193bbfd07e0 100644
--- a/docs/specs/components/Dialog/Dialog.spec.md
+++ b/docs/specs/components/Dialog/Dialog.spec.md
@@ -91,7 +91,7 @@ current family and architecture owners.
| FR4 | Closing a native Dialog MUST return focus to the still-connected external element that invoked the modal when that element can receive focus. Descendant mount focus MUST NOT replace that return owner. | shipped trigger-return intent and regression history | settled |
| FR5 | Inline rendering MUST NOT open a modal, apply modal initial focus, trap focus, or return focus as if a modal had closed. | documented `isInline` preview behavior | settled |
| FR6 | Initial-focus selection MUST follow the rendered eligible descendants, not a private component type or source-only assumption. | composable descendant contract | settled |
-| FR7 | A native modal Dialog MUST be a focus region through Core's shared focus-region module: Tab from its last tabbable control moves focus to its first, and Shift+Tab from its first moves focus to its last, so focus never leaves the modal for the browser's own interface. When a Dialog opens from a Dialog, the innermost one holding focus answers Tab. | owner decision DEC-2 | settled |
+| FR7 | A native modal Dialog MUST be a focus region through Core's shared focus-region module: Tab from its last tabbable control moves focus to its first, and Shift+Tab from its first moves focus to its last, so focus never leaves the modal for the browser's own interface. Tab and Shift+Tab wrap with or without Alt (Option), since Safari moves focus on Option+Tab; Tab chorded with Ctrl or Meta passes through to the browser. When a Dialog opens from a Dialog, the innermost one holding focus answers Tab. | owner decision DEC-2 | settled |
| FR8 | A modal Dialog open on the server renders closed until hydration (no open styles, no `aria-modal`, the markup of a closed Dialog) and opens in the render after hydration, the first that can make it modal; it never claims a modality it does not have (`spec:AST-020` FR1, WCAG 2.2 SC 4.1.2). An inline Dialog is not modal and renders as it is. The server and hydrating renders produce the same markup (`spec:AST-013` FR9), and the open after hydration is settled, with no entry motion; later opens and closes animate as usual (`spec:AST-013` FR13). | `spec:AST-020` FR1 | settled |
| FR9 | On every pointer, the modal backdrop is the theme's `--color-overlay` dim with no blur (no `backdrop-filter`); under forced colors the dialog keeps a visible edge (a system-colour border) so it stays apart from the page. A full-screen Dialog (`variant="fullscreen"`) covers the page and draws none. | Owner decision, 2026-10-25: the overlay stays theme-controlled and unblurred on every pointer. | settled |
@@ -147,16 +147,16 @@ theming owners. FR9 sets the backdrop's appearance.
## Verification map
-| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
-| ------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
-| FR1–FR3, AR1 | shared modal-dialog binding in `Dialog.a11y.test.tsx` and `Dialog.a11y.chromium.spec.ts`; local DialogHeader focus tests | header default, explicit descendant, no request | the shared contract fails when focus is applied before native modality, misses the declared target, or leaves the visible modal on native fallback | audit:Dialog/accessibility |
-| FR4, AR2 | shared modal-dialog browser binding plus the local unavailable-invoker close test | external trigger, descendant mount focus, removed trigger | the shared contract records an exact current failure when descendant focus replaces the return owner; the local test keeps close safe after invoker removal | audit:Dialog/behavior |
-| AR2 | `Dialog.test.tsx` "unmounting while open" cases | dialog rendered only while open and unmounted by its own action; removed with its view; focus moved elsewhere first; StrictMode | focus left on the page body after an open dialog unmounts, or focus pulled back from where the user moved it, fails | audit:Dialog/behavior |
-| FR5 | inline Dialog tests | DialogHeader and focusable children | inline preview steals focus or invokes native modal methods | audit:Dialog/behavior |
-| FR6, AR3 | shared modal-dialog binding plus the local composed-action focus fixture | DialogHeader heading, TextInput, action button | focus depends on a private React child type or strips semantics from the target | audit:Dialog/public-api |
-| FR7 | `Dialog.test.tsx`, `AlertDialog.test.tsx` and `CommandPalette.test.tsx` focus-region suites | last and first tabbable control, Dialog in Dialog, inline | Tab from the last control leaves the modal, or an outer Dialog answers an inner one's Tab | audit:Dialog/accessibility |
-| FR8 | `Dialog.ssr.test.tsx` | Open on the server; hydration; a closed Dialog's markup | An open server render with `aria-modal`, open styles, or markup unlike a closed one, a hydration error, no `showModal()` after hydration, an entry motion on the open right after hydration, or none on a later open fails. | audit:Dialog/accessibility |
-| FR9 | `modalBackdrop.test.tsx`; `ModalBackdrop.a11y.browser.spec.ts` in Chromium and WebKit | A finger at phone width; a mouse at desktop width; forced colors under either pointer; a full-screen Dialog in forced colors | A `backdrop-filter` on the backdrop at either width, no visible edge in forced colors, or an edge on a full-screen Dialog, fails. | audit:Dialog/accessibility |
+| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
+| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
+| FR1–FR3, AR1 | shared modal-dialog binding in `Dialog.a11y.test.tsx` and `Dialog.a11y.chromium.spec.ts`; local DialogHeader focus tests | header default, explicit descendant, no request | the shared contract fails when focus is applied before native modality, misses the declared target, or leaves the visible modal on native fallback | audit:Dialog/accessibility |
+| FR4, AR2 | shared modal-dialog browser binding plus the local unavailable-invoker close test | external trigger, descendant mount focus, removed trigger | the shared contract records an exact current failure when descendant focus replaces the return owner; the local test keeps close safe after invoker removal | audit:Dialog/behavior |
+| AR2 | `Dialog.test.tsx` "unmounting while open" cases | dialog rendered only while open and unmounted by its own action; removed with its view; focus moved elsewhere first; StrictMode | focus left on the page body after an open dialog unmounts, or focus pulled back from where the user moved it, fails | audit:Dialog/behavior |
+| FR5 | inline Dialog tests | DialogHeader and focusable children | inline preview steals focus or invokes native modal methods | audit:Dialog/behavior |
+| FR6, AR3 | shared modal-dialog binding plus the local composed-action focus fixture | DialogHeader heading, TextInput, action button | focus depends on a private React child type or strips semantics from the target | audit:Dialog/public-api |
+| FR7 | `Dialog.test.tsx`, `AlertDialog.test.tsx` and `CommandPalette.test.tsx` focus-region suites, and `useFocusTrap.test.tsx` "useFocusTrap chorded Tab" | last and first tabbable control, Dialog in Dialog, inline | Tab from the last control leaves the modal, or an outer Dialog answers an inner one's Tab | audit:Dialog/accessibility |
+| FR8 | `Dialog.ssr.test.tsx` | Open on the server; hydration; a closed Dialog's markup | An open server render with `aria-modal`, open styles, or markup unlike a closed one, a hydration error, no `showModal()` after hydration, an entry motion on the open right after hydration, or none on a later open fails. | audit:Dialog/accessibility |
+| FR9 | `modalBackdrop.test.tsx`; `ModalBackdrop.a11y.browser.spec.ts` in Chromium and WebKit | A finger at phone width; a mouse at desktop width; forced colors under either pointer; a full-screen Dialog in forced colors | A `backdrop-filter` on the backdrop at either width, no visible edge in forced colors, or an edge on a full-screen Dialog, fails. | audit:Dialog/accessibility |
## Decision log
diff --git a/docs/specs/components/Popover/Popover.spec.md b/docs/specs/components/Popover/Popover.spec.md
index f8d234ec28d95..150b935364c5d 100644
--- a/docs/specs/components/Popover/Popover.spec.md
+++ b/docs/specs/components/Popover/Popover.spec.md
@@ -220,20 +220,20 @@ table owns their semantic effect.
### Representative focus and dismissal states
-| State | Required invariant | Allowed variation |
-| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
-| Pointer or touch activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. Shared modality-aware indication suppresses keyboard-only rings after pointer input. | The trigger and content may vary. |
-| Keyboard or AT-style activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. The eligible destination paints the shared keyboard focus indication. | Activation with absent or zero event detail follows this path. Named AT announcement remains unverified. |
-| Read-only dialog | With no genuine caller control, the dialog surface receives initial focus. The injected fallback close control remains hidden and reachable by Tab but is never selected initially. | Caller content may contain no tabbable control. |
-| Neutral-role popup | The neutral wrapper is not selected as a dialog focus target; automatic entry prefers genuine content focus, or callers may disable autofocus for trigger-retained patterns. | Child menu, listbox, or other semantics own their focus model. |
-| Automatic focus disabled | Opening does not move focus. | May be persistent configuration or a one-opening show preference. |
-| Controlled open | External open state invokes the no-activation opening path: first genuine content control, then dialog-container fallback. | The caller remains the visibility source of truth. |
-| Tab or Shift+Tab inside the open popup | Focus moves to the next or previous tabbable descendant and wraps at either end, so it stays inside the popup, modal or not. | A role-neutral menu closes on Tab and a listbox keeps focus in its input, as their own records state. |
-| Tab or Shift+Tab from outside the open popup | The popup does not capture it: focus moves to the next or previous control in the page's own order, as if the popup were not there. | The browser's order, which places an anchored popup after its trigger, may still lead into it. |
-| Tab from a focused container | Focus enters the first tabbable descendant. | If none exists, focus remains inside. |
-| Shift+Tab from a focused container | Focus enters the last tabbable descendant. | If none exists, focus remains inside. |
-| Escape | The topmost eligible Popover closes according to shared dismissal policy. | Explicit-dismiss surfaces may disable both light and Escape dismissal. |
-| Close after focus entered the popup | Focus returns to the trigger when removal would otherwise strand focus. | A caller may additionally own focus return through its hide notification. |
+| State | Required invariant | Allowed variation |
+| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
+| Pointer or touch activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. Shared modality-aware indication suppresses keyboard-only rings after pointer input. | The trigger and content may vary. |
+| Keyboard or AT-style activation of dialog Popover | Initial focus enters the first genuine content control, with dialog-surface fallback when none exists. The eligible destination paints the shared keyboard focus indication. | Activation with absent or zero event detail follows this path. Named AT announcement remains unverified. |
+| Read-only dialog | With no genuine caller control, the dialog surface receives initial focus. The injected fallback close control remains hidden and reachable by Tab but is never selected initially. | Caller content may contain no tabbable control. |
+| Neutral-role popup | The neutral wrapper is not selected as a dialog focus target; automatic entry prefers genuine content focus, or callers may disable autofocus for trigger-retained patterns. | Child menu, listbox, or other semantics own their focus model. |
+| Automatic focus disabled | Opening does not move focus. | May be persistent configuration or a one-opening show preference. |
+| Controlled open | External open state invokes the no-activation opening path: first genuine content control, then dialog-container fallback. | The caller remains the visibility source of truth. |
+| Tab or Shift+Tab inside the open popup | Focus moves to the next or previous tabbable descendant and wraps at either end, so it stays inside the popup, modal or not. It wraps with or without Alt (Option); Tab chorded with Ctrl or Meta passes through to the browser. | A role-neutral menu closes on Tab and a listbox keeps focus in its input, as their own records state. |
+| Tab or Shift+Tab from outside the open popup | The popup does not capture it: focus moves to the next or previous control in the page's own order, as if the popup were not there. | The browser's order, which places an anchored popup after its trigger, may still lead into it. |
+| Tab from a focused container | Focus enters the first tabbable descendant. | If none exists, focus remains inside. |
+| Shift+Tab from a focused container | Focus enters the last tabbable descendant. | If none exists, focus remains inside. |
+| Escape | The topmost eligible Popover closes according to shared dismissal policy. | Explicit-dismiss surfaces may disable both light and Escape dismissal. |
+| Close after focus entered the popup | Focus returns to the trigger when removal would otherwise strand focus. | A caller may additionally own focus return through its hide notification. |
### Transformation and precedence order
diff --git a/packages/core/src/hooks/useFocusTrap.test.tsx b/packages/core/src/hooks/useFocusTrap.test.tsx
index 27f12516a208c..27afb5d2fad18 100644
--- a/packages/core/src/hooks/useFocusTrap.test.tsx
+++ b/packages/core/src/hooks/useFocusTrap.test.tsx
@@ -547,6 +547,65 @@ describe('useFocusTrap tabbable model (infra-8)', () => {
});
});
+describe('useFocusTrap chorded Tab', () => {
+ function renderEnds() {
+ render(
+
+
+
+ ,
+ );
+ return {
+ first: screen.getByTestId('first'),
+ last: screen.getByTestId('last'),
+ };
+ }
+
+ it('leaves Ctrl+Tab and Meta+Tab at either end to the browser', () => {
+ const {first, last} = renderEnds();
+
+ last.focus();
+ expect(fireEvent.keyDown(last, {key: 'Tab', ctrlKey: true})).toBe(true);
+ expect(fireEvent.keyDown(last, {key: 'Tab', metaKey: true})).toBe(true);
+ expect(last).toHaveFocus();
+
+ first.focus();
+ expect(
+ fireEvent.keyDown(first, {key: 'Tab', ctrlKey: true, shiftKey: true}),
+ ).toBe(true);
+ expect(
+ fireEvent.keyDown(first, {key: 'Tab', metaKey: true, shiftKey: true}),
+ ).toBe(true);
+ expect(first).toHaveFocus();
+ });
+
+ it('still wraps Alt+Tab and Alt+Shift+Tab, which move focus in Safari', () => {
+ const {first, last} = renderEnds();
+ last.focus();
+
+ expect(fireEvent.keyDown(last, {key: 'Tab', altKey: true})).toBe(false);
+ expect(first).toHaveFocus();
+ expect(
+ fireEvent.keyDown(first, {key: 'Tab', altKey: true, shiftKey: true}),
+ ).toBe(false);
+ expect(last).toHaveFocus();
+ });
+
+ it('still wraps a plain Tab and Shift+Tab at either end', () => {
+ const {first, last} = renderEnds();
+ last.focus();
+
+ expect(fireEvent.keyDown(last, {key: 'Tab'})).toBe(false);
+ expect(first).toHaveFocus();
+ expect(fireEvent.keyDown(first, {key: 'Tab', shiftKey: true})).toBe(false);
+ expect(last).toHaveFocus();
+ });
+});
+
describe('FOCUSABLE_SELECTOR href matching', () => {
// Only real links (/) are focusable via href. A bare
// [href] term also matched non-focusable elements carrying href (e.g. a
diff --git a/packages/core/src/hooks/useFocusTrap.ts b/packages/core/src/hooks/useFocusTrap.ts
index 0627ec6d2f783..7566a0dda8f43 100644
--- a/packages/core/src/hooks/useFocusTrap.ts
+++ b/packages/core/src/hooks/useFocusTrap.ts
@@ -399,6 +399,12 @@ export function useFocusRegionContainment(
}
if (event.key === 'Tab') {
+ if (event.ctrlKey || event.metaKey) {
+ // Ctrl+Tab and Meta+Tab switch tabs or apps. They do not move
+ // focus, so the region leaves them to the browser and the page.
+ // Alt+Tab stays: Safari moves focus to each item on Option+Tab.
+ return;
+ }
if (event.defaultPrevented) {
// A control that handles Tab itself (an editor that indents) keeps
// focus where it is.