How to Test Scroll Lock, Body Freezing, and Overscroll Containment in Overlay-Heavy UIs Without Brittle Assertions
By Antoine Dubois · August 22, 2026
A practical guide to test scroll lock in browser automation for modals, drawers, and popovers, including body freezing, scroll restoration, and mobile overscroll edge cases.
Scroll lock bugs are annoying because they are easy to miss and hard to assert cleanly. A modal can look correct while the page behind it still scrolls, the body position can jump when the overlay closes, and mobile browsers can ignore your expectations if touch overscroll is not contained.
The useful question is not, “Did the modal open?” It is, “Can the user move the background page while the overlay is active, and does the page return to the same place after close?” That is the behavior to verify when you test scroll lock in browser automation.
Define the behavior first, then test the user-visible result. Avoid asserting one implementation detail unless your app depends on it.
What scroll lock actually includes
These terms are often used interchangeably, but they are not the same:
- Body freezing usually means the document body is fixed or overflow-hidden while an overlay is open.
- Scroll lock is the user-facing result, background content does not move.
- Overscroll containment prevents scroll chaining, where a scroll gesture on an inner overlay leaks to the page behind it.
Modern overlay systems often combine all three. A drawer may set overflow: hidden on html or body, a modal may use position: fixed to preserve the page offset, and a nested panel may use CSS overscroll-behavior to stop gesture chaining. The behavior is described in the CSS Overflow Module, and MDN has useful summaries for overscroll-behavior and position.
The failure modes worth testing
For overlay-heavy UIs, I would cover four failure modes before anything else:
- Background scroll still moves when the modal is open.
- Scroll position is lost when the modal closes.
- Touch scrolling leaks from a scrollable overlay into the page on mobile.
- Nested overlays conflict, for example, a drawer inside a modal restores the wrong scroll state.
Those failures are user-visible and regression-prone. A test that only checks for a CSS class like is-scroll-locked can miss all four.
A practical testing strategy
Use a layered approach:
- State assertion, verify the overlay is open and focus is trapped where expected.
- Behavior assertion, try to scroll the background and confirm it does not move.
- Restoration assertion, close the overlay and confirm the page returns to the original scroll position.
- Device-specific assertion, repeat on a mobile viewport with touch input when your product supports it.
That sequence keeps tests stable because it checks what the user can observe, not just the internals your current implementation happens to use.
What not to over-assert
Avoid these as your only checks:
- exact class names on
body - one particular style rule, such as
overflow: hidden - a specific implementation of scroll freezing
- pixel-perfect layout around the overlay backdrop
Those can all change while the behavior stays correct. If you assert implementation details, do it only when the implementation itself is part of the contract, such as a design system component that guarantees body freezing through a specific shared utility.
A Playwright pattern that checks behavior instead of internals
Playwright is a good fit here because it can measure page state, trigger real input, and run the same test in desktop and mobile-like contexts. The Playwright docs cover browser contexts, mobile emulation, and input methods.
This example records the scroll position, opens an overlay, tries to scroll the page, and confirms the position does not change.
import { test, expect } from '@playwright/test';
test('modal prevents background scroll', async ({ page }) => {
await page.goto('https://example.com/products');
await page.evaluate(() => window.scrollTo(0, 1200));
const before = await page.evaluate(() => window.scrollY);
await page.getByRole('button', { name: 'Open filters' }).click();
// Try to scroll the page behind the modal.
await page.mouse.wheel(0, 800);
const after = await page.evaluate(() => window.scrollY);
await expect(after).toBe(before);
});
This is intentionally simple. The important part is that it measures window.scrollY before and after an actual scroll action. If the page shifts, the test fails for the right reason.
Better still, assert restore behavior
Many regressions only appear on close. If body freezing is implemented by fixing the body position, the overlay may close and leave the user at the wrong scroll location unless the previous offset is restored correctly.
import { test, expect } from '@playwright/test';
test('modal restores scroll position after close', async ({ page }) => {
await page.goto('https://example.com/products');
await page.evaluate(() => window.scrollTo(0, 1600));
const before = await page.evaluate(() => window.scrollY);
await page.getByRole('button', { name: 'Open filters' }).click();
await page.getByRole('button', { name: 'Close' }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(before);
});
expect.poll is useful when the close animation or cleanup happens asynchronously. It avoids a brittle fixed sleep.
Testing body freezing without coupling to one CSS trick
There are several ways to freeze the body:
overflow: hiddenonhtmlorbodyposition: fixedwith a saved top offset- a wrapper element that becomes the scroll container
Do not hard-code one method in your test unless your component library mandates it. If the public requirement is “background must not move,” assert that requirement directly.
That said, if your app has a long history of scroll-jump bugs, one implementation-level check can help debug failures faster. For example, after opening the overlay, you may inspect whether the active scroll container changed or whether body got a position: fixed style.
const bodyStyle = await page.evaluate(() => {
const style = getComputedStyle(document.body);
return {
position: style.position,
overflowY: style.overflowY,
};
});
Use that as a diagnostic aid, not the primary pass/fail gate.
Testing overscroll containment on mobile browsers
Overscroll containment matters when the overlay itself scrolls. Without it, a user can reach the top or bottom of the modal and keep dragging, which may scroll the page behind it. On mobile, that can feel like the page is slipping out from under the dialog.
The CSS property you care about is overscroll-behavior. Your test should target two cases:
- scrolling inside the overlay should work
- scrolling past the edge of the overlay should not move the page behind it
A mobile-oriented Playwright context helps reproduce the interaction path.
import { devices, test, expect } from '@playwright/test';
test.use({ …devices[‘iPhone 14’] });
test('drawer contains touch overscroll', async ({ page }) => {
await page.goto('https://example.com/products');
await page.evaluate(() => window.scrollTo(0, 1000));
await page.getByRole('button', { name: 'Open menu' }).click();
const before = await page.evaluate(() => window.scrollY);
const panel = page.getByTestId('drawer-panel');
await panel.hover();
await page.touchscreen.tap(200, 300);
await page.mouse.wheel(0, -1200);
await expect(page.evaluate(() => window.scrollY)).resolves.toBe(before);
});
That example is not a perfect simulation of every mobile gesture. It is still useful because it tests the page-level outcome. If your product is especially sensitive to touch behavior, add a device-cloud run on a real mobile browser.
A small decision table for what to assert
| Scenario | Best assertion | Why it matters |
|---|---|---|
| Modal opens on desktop | window.scrollY stays unchanged after wheel input |
Verifies background scroll prevention |
| Modal closes after page was scrolled | window.scrollY returns to the original value |
Catches scroll restoration bugs |
| Drawer with internal scroll area | Internal panel scrolls, page does not | Separates overlay scrolling from page scrolling |
| Mobile overlay with long content | Touch scroll stays contained within the overlay | Catches overscroll chaining and gesture leakage |
| Design system wrapper with fixed body locking | Inspect body styles only as a diagnostic | Helps debug implementation without overfitting tests |
When a single test is not enough
One happy-path test will not cover all overlay behavior. I would split coverage by intent:
1. Component test, if the overlay is reusable
Verify the reusable modal or drawer locks background scroll and restores it on close.
2. Page flow test, if the overlay sits inside a real workflow
Verify a product page with filters, menus, or search facets still behaves correctly after navigation, back/forward actions, and reopen cycles.
3. Mobile regression test, if you ship touch UIs
Run one focused mobile scenario for scroll chaining, because desktop wheel events do not reproduce touch overscroll bugs well.
4. Accessibility check, if the overlay traps focus
Scroll lock and focus management are related but not identical. A modal can block background scroll and still leave background elements focusable. If you already test keyboard behavior, keep that separate and explicit.
Debugging a failing scroll lock test
When the test fails, the root cause is usually one of these:
- the overlay locks
body, but the page scroll container is actually a wrapper div - the lock applies too late, after the first frame of the overlay animation
- the body is fixed, but the previous scroll offset is not restored
- a nested scroll area lacks
overscroll-behavior: containornone - the test is using a synthetic interaction that does not match the browser input path you need
The fastest debug path is:
- capture
window.scrollYbefore and after the interaction - inspect which element actually scrolls in your app
- verify the overlay container’s computed
overflowandoverscroll-behavior - repeat on one mobile viewport if the defect is touch-related
If the page uses a custom scroll root, test that element instead of window. That is the most common source of false confidence in scroll lock checks.
A concise recommendation
If you need to test scroll lock in browser automation, make the test prove three things: the background does not move, the page returns to the same place after close, and touch scrolling does not leak past the overlay on mobile.
My default recommendation is to assert user-visible behavior first, then add one implementation-level diagnostic only when it helps explain failures. That keeps overlay UI regression testing resilient when the locking mechanism changes, which it often does.
FAQ
Should I assert overflow: hidden on body?
Only if your component contract requires that exact implementation. Otherwise, assert the outcome, background scroll does not move.
How do I test scroll lock on a page that scrolls inside a wrapper instead of the window?
Measure and manipulate the real scroll container, not window. In many apps, that means querying the main scrolling element or a layout wrapper.
Is wheel input enough to test mobile overscroll behavior?
No. Wheel input is useful for desktop coverage, but mobile overscroll often needs a device-specific run because touch gesture behavior is different.
What is the most common false positive in scroll lock tests?
A test that checks the modal is visible but never attempts to scroll the background. That can pass even when the page still moves.
Do I need separate tests for scroll restoration?
Yes. Restoration bugs often appear only after close, and they are easy to miss if you only assert that the overlay opened correctly.