GalleryExhibit 01
The drawer that let the page slide out from under it
A mobile navigation drawer left the page behind it scrollable, and the fix for that made the page take a second and a half to scroll back into place every time the drawer closed.
A study interface read mostly on a phone, where the sidebar collapses into a drawer on narrow screens.
What you can check here. Three code paths in one component, switchable above. The difference between the last two is asserted frame by frame rather than after the scroll settles.
The object
See it happen
Scroll the phone with the wheel, a finger, or the controls next to it.
Five frames after the close
Close the drawer from somewhere in the middle to sample them.
What the page did
Nothing yet — try a control above.
The phone in the case is a scroll container in this page, and it really does carry scroll-behavior: smooth, so the difference between First fix and Fixed is the browser’s own behaviour, at this browser’s speed. Two things are simplified. Focus is only ever returned with preventScroll, so only the scroll restore is shown. And where the browser does not animate the scroll itself, the case plays a shorter stand-in, in steps if you have asked for reduced motion, so there is still something to watch.
What you are looking at
The scrim stops clicks, not scrolling — the page keeps moving behind the drawer.
- Scroll the phone to somewhere in the middle, then open the drawer.
- Scroll over the scrim — the dimmed strip to the right of the drawer — or press Push the background, and watch the page behind it move.
- Close the drawer: you are no longer where you left off.
Wall text
What happened
The interface was read mostly on phones, so on narrow screens the sidebar became a drawer with a translucent scrim behind it. Someone opened the drawer halfway down a long page, dragged a finger over the scrim, and the page underneath went with it.
A scrim is a click target. It absorbs pointer events, which is why tapping it closes the drawer — but it does not stop the page underneath from scrolling. Dragging over it moved the background by the better part of a hundred pixels. Closing the drawer then left you somewhere other than where you had been reading, which looks like the page jumping.
The first fix locked the body. That worked, and it introduced a second, quieter defect: closing the drawer dropped the scroll offset to zero and then took about a second and a half to travel back. Nothing was broken any more; the page simply scrolled all the way back while you watched.
Root cause
Locking a page means stopping the document itself from scrolling. The most widely compatible way to do that on mobile Safari is position: fixed on the body with a negative top equal to the current scroll offset: older versions ignore overflow: hidden on the body, and a phone is exactly where this bug lives.
But while the body is fixed, the document’s own scroll offset is zero. Unlocking therefore has to put it back by hand, with window.scrollTo. And window.scrollTo is not a jump: it follows the CSS scroll-behavior of the scrolling element. The stylesheet set scroll-behavior: smooth on html globally, months earlier, for anchor links. The restore was governed by that rule and became an animation.
Returning focus to the hamburger button had the same problem from a different direction. If the button is off-screen once the position is restored — on a page whose header scrolls away, it is — element.focus() scrolls back up to it, smoothly, and undoes a restore that has only just landed.
Why the first fix was not the end of it
It did not fail — it was correct, and incomplete. The lock was right; the way out of the lock picked up a global CSS rule nobody was thinking about at the time.
Many real bugs have this shape. position: fixed and scrollTo are each individually right, and the seam between them picks up scroll-behavior: smooth from a stylesheet written for a completely different reason.
The complete fix is small but exacting: set the root element’s inline scroll-behavior to auto, restore the position, then put the previous inline value back rather than leaving it at auto. Otherwise every other smooth scroll on the site is permanently disabled by the drawer. And return focus with preventScroll: true.
The change
Code
html {
/* For in-page anchor links, long before there was a drawer. */
scroll-behavior: smooth;
}
useEffect(() => {
if (!drawerOpen || !isNarrow) return;
const offset = window.scrollY;
const { style } = document.body;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
const saved = { /* the inline styles this effect overwrites */ };
Object.assign(style, {
position: "fixed",
top: `-${offset}px`,
left: "0",
right: "0",
});
// The scrollbar disappears with the lock; pad so nothing shifts.
if (scrollbar > 0) style.paddingRight = `${scrollbar}px`;
return () => {
Object.assign(style, saved);
if (!restoreOnUnlock.current) return; // following a link: no restore
- window.scrollTo(0, offset);
+ // An inline style outranks the stylesheet, so this one call jumps;
+ // the old inline value goes straight back afterwards.
+ const html = document.documentElement;
+ const savedBehavior = html.style.scrollBehavior;
+ html.style.scrollBehavior = "auto";
+ window.scrollTo(0, offset);
+ html.style.scrollBehavior = savedBehavior;
};
if (event.key === "Escape") {
event.preventDefault();
closeDrawer();
- menuButtonRef.current?.focus();
+ // A plain focus() scrolls the button into view, smoothly, and
+ // undoes the restore that has only just happened.
+ menuButtonRef.current?.focus({ preventScroll: true });
return;
}
const sampleFrames = useCallback(() => {
const vp = viewportRef.current;
if (!vp) return;
const seen: number[] = [];
let n = 0;
const step = () => {
seen.push(Math.round(vp.scrollTop));
n += 1;
if (n < 5) {
requestAnimationFrame(step);
} else {
setFrames(seen);
}
};
requestAnimationFrame(step);
}, []);
The test that catches it
Proof it stays fixed
The interesting problem with testing this is that patience hides it. Wait two seconds after closing the drawer and the animated restore has arrived too — the broken version passes.
So the simulation samples five consecutive requestAnimationFrame callbacks after every close and puts the numbers on screen, and the test asserts against the second frame rather than the settled value. In the Fixed state the strip reads the same number five times; in First fix it reads something like 0 → 2 → 12 → 33 → 79.
A separate test asserts that the phone’s scroll container really is in scroll-behavior: smooth mode. Without it the whole suite would pass for the wrong reason on a browser that ignores the rule, and the exhibit would be demonstrating nothing.
The rest of the spec attacks the lock directly: wheel events over the scrim and direct scrollTop writes, at 1280px and 390px, and all three ways of closing landing on the same pixel.
test("Fixed: the position is back by the second frame", async ({ page }) => {
await selectState(page, /^Fixed$/);
const y = await scrollToMiddle(page);
await openDrawer(page);
await page.getByRole("button", { name: "Push the background" }).click();
await expect(log(page).getByText(/the page is locked/)).toBeVisible();
await page.getByRole("button", { name: "Close the drawer" }).click();
const verdict = page.getByTestId("frame-verdict");
await expect(verdict).toContainText("by frame two");
await expect(verdict).toContainText(String(y));
expect(await scrollTop(page)).toBe(y);
});
How it went
Discovery to regression test
Discovered
The page moved behind the drawer
Reported from a phone: open the sidebar halfway down a long page, drag over the scrim, and the page scrolls. The Broken state in the case reproduces it — the readout shows the offset changing while the drawer is open.
The simulation ↗First fix
Lock the body with position: fixed
position: fixedwith a negativetop, notoverflow: hidden, because older mobile Safari ignores the latter on the body. Restore the offset on close, skip the restore when navigating away, and pad for the disappearing scrollbar.Final fix
Restore instantly, and give focus back without scrolling
Set the root element’s inline
scroll-behaviortoautoaround thescrollTo, put the previous inline value back afterwards, and return focus withpreventScroll: true.Pinned by a test
Sampled frame by frame, at two widths, three ways out
The spec refuses to wait for the scroll to settle and asserts on the second frame instead. It also asserts that the container is genuinely smooth-scrolling, so it cannot pass vacuously.
tests/e2e/drawer.spec.ts ↗
Verify it yourself
Sources
- Exhibit datacontent/exhibits/drawer-scroll-lock.ts ↗Every word on this page, as data.
- Simulationcomponents/sims/drawer/drawer-sim.tsx ↗All three code paths, and the frame sampler.
- Simulationcomponents/sims/drawer/drawer.module.css ↗Where the phone’s scroll container gets scroll-behavior: smooth.
- Testtests/e2e/drawer.spec.ts ↗The frame sampling, the lock and every way out, at two viewport widths.