How to Test the `inert` Attribute, Modal Background Locking, and Tabbable Leakage Without Brittle Assertions
By Antoine Dubois · October 8, 2026
A practical tutorial for testing modal background locking with `inert`, focus order, pointer blocking, escape paths, and tabbable leakage in browser automation.
The hardest part of modal testing is not proving that the dialog appears. It is proving that everything behind it becomes unreachable, by keyboard, pointer, and screen-reader-adjacent focus navigation. That is where brittle tests usually show up: asserting a CSS class, waiting for an animation, or checking a single Tab press and calling it done.
If your component library uses inert, you can test modal background locking more reliably by checking behavior, not implementation. The useful question is not, “Did the app set inert?” It is, “Can the user still reach the page underneath while the modal is open?”
inert and modal locking are related, but not the same thing
The inert attribute makes a subtree non-interactive. In a modal flow, teams often apply it to the application shell, the page content wrapper, or everything except the dialog. That is a mechanism. Modal background locking is the user-visible outcome.
For testing, keep these separate:
inertsupport: whether the browser or polyfill prevents interaction with that subtree.- Modal background locking: whether pointer, keyboard, and focus movement stay inside the dialog.
- Tabbable leakage: whether a hidden or background control can still receive focus.
A passing
inertattribute is not the same as a passing modal. The user only cares about the behavior of the whole interaction.
For accessibility expectations around modal dialogs, the WCAG standard is the right backdrop, especially for focus order, keyboard operation, and user control. Your tests should reflect that user-observable behavior.
What to verify for a modal that locks the background
Use a small set of checks that map to user risk. You do not need dozens of assertions.
| Concern | What you verify | Why it matters |
|---|---|---|
| Focus trap | Tab and Shift+Tab stay inside the modal |
Prevents keyboard leakage to background controls |
| Pointer blocking | Clicking the page behind the modal does nothing | Prevents accidental interaction with inactive content |
| Escape path | Esc closes the dialog if your product supports it |
Confirms a keyboard exit path |
| Background state | Background controls are effectively unreachable | Confirms the lock, regardless of implementation |
| Restoration | Focus returns to a sensible element after close | Avoids focus loss and disorientation |
You do not need to assert the internal structure of the dialog. A test that says “the backdrop exists” can still miss a broken focus trap. A test that only checks one button can miss tabbable leakage through a different control.
The testing pattern that avoids brittleness
The most stable approach is to pick a few user-visible target points and probe them from the outside.
- Open the modal.
- Try to move focus forward and backward.
- Try to click a clearly reachable element behind the modal.
- Try the escape path.
- Confirm focus restoration after close.
That sequence catches the failures that matter:
- modal opens but background is still clickable,
- modal opens but
Tablands on a background link, - modal closes but focus disappears into
body, - polyfill appears active but only blocks pointer events, not focus.
A Playwright example that checks behavior, not internals
This example uses a background button, a modal, and the keyboard focus cycle. It avoids depending on framework-specific class names.
import { test, expect } from '@playwright/test';
test('modal locks background and keeps focus inside', async ({ page }) => {
await page.goto('/dialog-demo');
const open = page.getByRole('button', { name: 'Open dialog' });
const backgroundAction = page.getByRole('button', { name: 'Background action' });
await open.click();
const dialog = page.getByRole('dialog', { name: 'Settings' });
await expect(dialog).toBeVisible();
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: 'Save' })).toBeFocused();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await expect(dialog).toBeVisible();
await backgroundAction.click({ trial: true });
await expect(backgroundAction).toBeEnabled();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(open).toBeFocused();
});
A few notes about this style:
getByRole(...)makes the test closer to user navigation than CSS selectors do.trial: trueis useful when you want to validate whether Playwright can target the element without actually triggering the click. That is helpful for background-interaction checks.toBeFocused()is more useful than checkingdocument.activeElementyourself, because it keeps the intent readable.
If your modal does not restore focus to the opener, change the final assertion to match your product rule, but do not leave the close path unverified.
How to detect tabbable leakage
Tabbable leakage is usually the failure that slips past visual checks. The page looks blocked, but keyboard focus still escapes to a hidden link, a search field, or a skip link.
A practical probe is to record the sequence of focused elements while pressing Tab several times. You are not testing every possible element. You are checking that focus never leaves the active dialog.
import { test, expect } from '@playwright/test';
test('tab order stays inside the modal', async ({ page }) => {
await page.goto('/dialog-demo');
await page.getByRole('button', { name: 'Open dialog' }).click();
const seen: string[] = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
seen.push(await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
return el?.getAttribute('aria-label') || el?.textContent?.trim() || el?.tagName || '';
}));
}
expect(seen.join(' | ')).not.toContain('Background');
});
That example is intentionally lightweight. In a real test, prefer asserting the current focused role or accessible name rather than raw text where possible.
Better than checking every tabbable element
It is tempting to enumerate every button and link in the background and assert that they are not tabbable. That can become fragile when the page changes. A more robust strategy is to press Tab and see where focus actually goes.
That catches:
- stale
tabindexvalues, - elements reintroduced by a refactor,
- focusable controls rendered in a portal,
- partial
inertpolyfills that block clicks but not keyboard focus.
Pointer blocking needs a different probe
inert should prevent pointer interaction with the inactive subtree. But pointer blocking and keyboard blocking can fail independently.
To test background click blocking, choose a control that would visibly change state if activated, then verify that the state does not change while the modal is open.
await page.getByRole('button', { name: 'Open dialog' }).click();
await page.getByRole('button', { name: 'Background action' }).click({ trial: true });
await expect(page.getByText('Background clicked')).toHaveCount(0);
If your app uses an overlay backdrop that intercepts clicks, do not stop at asserting the overlay exists. Overlays can cover the page while keyboard focus still leaks out of the dialog.
Cross-browser quirks and polyfill risk
The testing shape stays the same whether inert comes from native browser support or a polyfill, but the failure modes differ.
What matters for your test plan:
- Native support: usually simpler, but still validate the user behavior.
- Polyfill support: inspect whether it suppresses both pointer and keyboard interaction, not just one of them.
- Portals and shadow DOM: modal content and page content may live in different DOM trees, which can make implementation-driven tests misleading.
If your component library renders the dialog in a portal, be careful with assertions that depend on DOM proximity. The dialog can be visually on top while background content remains in a separate subtree. That is fine, but your test should prove the lock still works.
Testing the DOM tree is not enough when focus and interaction are the real contract.
A small helper makes these tests easier to maintain
When several modal tests need the same checks, create a helper that exercises the interaction contract.
import { Page, expect } from '@playwright/test';
export async function expectModalLocksBackground(page: Page, dialogName: string) {
const dialog = page.getByRole('dialog', { name: dialogName });
await expect(dialog).toBeVisible();
await page.keyboard.press('Tab');
await expect(dialog).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
}
Keep the helper narrow. If it tries to verify every possible dialog variation, it becomes a second framework instead of a reusable check.
Common failure modes worth catching early
1. Only the overlay is tested
The modal looks correct, but a background link still receives focus.
2. Only the first tab stop is tested
The first button stays inside the dialog, but the second or third Tab escapes.
3. Pointer works, keyboard does not
Mouse clicks are blocked by the backdrop, but Tab reaches inactive controls.
4. Focus closes the dialog but does not restore cleanly
The dialog exits, but the focus lands on body or nowhere meaningful.
5. Polyfill behavior differs from native behavior
The dialog passes in one browser and fails in another because the implementation depends on how inert is emulated.
These failures are worth testing because they are user-facing and regress easily during design-system work.
Where I would put the assertions
For a component library modal, I would keep three levels of coverage:
- Unit or component test: verify the dialog opens and closes.
- Browser automation test: verify focus trap, pointer blocking,
Esc, and focus restoration. - Accessibility review or manual keyboard pass: verify the interaction feels coherent with a real keyboard.
That split keeps the browser automation suite focused on behavior that is expensive to reason about in isolation.
Not the best fit if you only need a visual smoke check
If your only goal is to confirm that a dialog renders, a full focus-and-lock test is overkill. A visual smoke test can be enough for simple appearance changes.
This tutorial matters when the modal has a real accessibility contract:
- the page underneath must not be reachable,
- keyboard users must stay in the dialog,
- the escape path must work,
- and the experience must survive refactors in the DOM structure.
That is the point where brittle assertions cost more than they save.
FAQ
Should I assert the inert attribute directly?
Only if the attribute itself is part of the contract you own. For most modal tests, the user-visible behavior is more important than the attribute value.
Is aria-hidden enough to test modal background locking?
No. aria-hidden affects exposure to assistive technology, but it does not guarantee that keyboard or pointer interaction is blocked.
Do I need separate tests for pointer and keyboard behavior?
Yes. They fail differently, and one can pass while the other is broken.
How many Tab presses are enough?
Enough to cover the focus cycle inside the dialog and expose leakage. Usually that means a small loop, not a single press.
What is the most stable locator for the modal?
Prefer role and accessible name, such as getByRole('dialog', { name: 'Settings' }), over CSS classes or framework internals.
What if my dialog uses a portal?
That is fine. Test the observable interaction contract, not the DOM placement.