Mobile Docs Navigation Drawer That Doesn’t Break: Overlay, Scroll Lock, and Focus

Was this helpful?

Docs sites love the slide-in navigation drawer. Users love it too—until it traps them on a page, scroll-janks their iPhone into oblivion, or makes keyboard navigation feel like spelunking without a headlamp.

If you run production systems (or you just own the pager for “the docs are unusable on mobile”), you don’t want a clever drawer. You want a boring, predictable drawer: correct overlay, reliable scroll lock, and focus handling that doesn’t regress every time a framework updates.

What “good” looks like in a docs drawer

A mobile docs navigation drawer has three jobs, and most implementations fail at least one:

  1. Reveal navigation without losing context: slide-in sidebar, clear hierarchy, quick close.
  2. Prevent the page behind it from interfering: real overlay, real scroll lock, no ghost clicks.
  3. Respect human input: touch, keyboard, screen readers, reduced motion, and weird browser edge cases.

“Good” means the following behaviors are true on the worst devices and the worst browsers (hello iOS Safari):

  • Open drawer: the rest of the page is inert (no scrolling, no clicking, no focus).
  • Close drawer: the page returns exactly to the prior scroll position, and focus returns to the button that opened it.
  • Keyboard: Tab stays inside the drawer; Esc closes it; the toggle is discoverable.
  • Touch: background doesn’t “rubber band” under the overlay; no accidental text selection.
  • Routing: navigation closes the drawer reliably and doesn’t leave the document in a locked state.
  • Performance: animations don’t cause layout thrash; overlay isn’t a GPU furnace.

Opinionated take: treat the drawer like a modal. Not visually, but behaviorally. If you wouldn’t allow the background to scroll behind a modal dialog, don’t allow it behind the nav drawer either. Docs pages are long, which means the failure modes are louder.

Facts and history that explain today’s drawer problems

Knowing how we got here makes current bugs less mysterious and more repeatable to fix.

  1. The “hamburger” icon wasn’t invented for phones. It appeared in early 1980s interface workstations; mobile just made it famous.
  2. Mobile Safari long resisted “proper” scroll locking because scrolling lived in a specialized compositor path; web devs tried to fight it with hacks.
  3. Fixed-position elements on iOS used to reflow during browser chrome show/hide (address bar collapsing). Some of those edge cases still show up as jitter.
  4. “100vh” historically lied on mobile because browser UI took space dynamically; drawers that assume stable viewport height get chopped or overflow weirdly.
  5. Stacking contexts got nastier as CSS features grew. Properties like transform, filter, and opacity create new stacking contexts; overlays mysteriously appear under headers.
  6. The web didn’t have an “inert” primitive for years, so teams faked it by disabling pointer events or intercepting focus. The inert attribute is now widely usable—but still needs testing.
  7. Framework hydration changed the failure profile. Server-rendered HTML that becomes interactive later can briefly allow background scroll or focus before JS attaches handlers.
  8. Backdrop blur became popular because it looks premium, and then everyone discovered it also looks like dropped frames on mid-tier devices.

One quote worth keeping on your wall, because drawers are deceptively “UI” and still part of reliability:

“Hope is not a strategy.” — General Gordon R. Sullivan

He wasn’t talking about focus traps. He could’ve been.

Architecture: overlay, panel, and the “one source of truth” state

The minimal architecture that behaves well:

  • Toggle button (usually in the header): controls state; has clear accessible name; reflects open/closed.
  • Backdrop/overlay: covers viewport; intercepts clicks/taps; optionally dims background; closing mechanism.
  • Drawer panel: off-canvas element that slides in; contains navigation; has a close button at top.
  • State manager: a single boolean, plus a tiny amount of metadata (what had focus before opening; scroll position lock).

Keep the state boring. You’re not building a distributed database. The moment you introduce “partially open”, “dragging open”, “open because hover”, you will spend your weekends chasing edge cases on devices you don’t own.

Model your drawer like a modal (but don’t overcomplicate it)

Use a small state machine in spirit, even if you implement it as a couple of flags:

  • Closed: no overlay; body scroll normal; focus normal.
  • Opening: set inert/scroll lock first, then animate. Prevent input during transition.
  • Open: trap focus; overlay active; close on overlay click, close button, Esc, and route change.
  • Closing: release focus trap after animation completes; restore scroll; restore focus.

The ordering matters. If you animate first and lock scroll second, users can scroll the page behind the drawer during the animation. They will. Immediately.

A quick joke, since we’re dealing with UI state

There are two hard problems in computer science: cache invalidation, naming things, and closing the nav drawer on route change.

Overlay and stacking contexts: why your backdrop is under the header

When the overlay doesn’t cover everything, it’s almost never “z-index needs to be higher” in isolation. It’s stacking contexts.

How you accidentally create a stacking context

Any of these on an ancestor can cause your overlay to behave like it’s trapped behind other UI:

  • transform (including transform: translateZ(0) “performance” hacks)
  • filter / backdrop-filter
  • opacity < 1
  • position plus z-index in certain layouts
  • isolation: isolate
  • will-change (yes, it can push elements into their own layers and change compositing behavior)

In docs sites, the usual culprit is a fixed header with transform set for smooth scrolling or micro-animations. Then your “global overlay” is not global anymore.

Practical guidance

  • Render overlay and drawer at the document root (portal to document.body), not inside a transformed container.
  • Use a dedicated top-level stacking layer: e.g., create #ui-layer as the last child of body. Avoid nesting inside main layout wrappers.
  • Don’t rely on magic z-index numbers. Set a small system: header 10, drawer 100, modal 1000. Then stick to it.

Overlay must block pointer events reliably

Use the overlay as a real element that captures pointer events. “Dim the background” by setting overlay background color, not by applying opacity to the entire page. If you fade the page, you also fade text, and screen readers won’t care but humans will.

Scroll lock: body locking, iOS Safari, and the trap of “position: fixed”

Scroll lock is where most drawers go to die. Desktop browsers often forgive sloppy locking. Mobile browsers remember, and then punish you with rubber band scrolling, jumpy content, and the dreaded “page returns to the top when closing.”

What you’re trying to achieve

When the drawer is open:

  • The document behind the overlay must not scroll.
  • The drawer panel itself can scroll (navigation lists are long).
  • Touch scrolling inside the drawer should not “chain” into body scroll.
  • When closing, return the body to the exact previous scroll position.

The robust pattern: lock body with fixed positioning and saved scrollY

This is the pattern that works across most mobile Safari versions:

  • Capture scrollY when opening.
  • Set body to position: fixed, top: -scrollY, left: 0, right: 0, width: 100%.
  • When closing, remove those styles and restore scroll with window.scrollTo(0, savedScrollY).

Yes, it feels like a hack. It is a hack. But it’s a stable hack, which is what we pay for.

Why not just use overflow: hidden on body?

Because mobile Safari is inconsistent about body scrolling: sometimes the scroll container is the html element, sometimes it’s the body, sometimes it depends on whether you’ve set a height. You can make overflow: hidden appear to work on your device and still ship a broken experience to someone else.

Stop scroll chaining with overscroll behavior

For the drawer’s internal scroll container:

  • Use overscroll-behavior: contain where supported to prevent scroll chaining into the body.
  • On iOS, also consider -webkit-overflow-scrolling: touch for smooth scrolling, but test—some combinations with fixed body can still jitter.

Viewport height: stop using raw 100vh on mobile

Prefer dynamic viewport units (dvh) where available, and provide fallbacks. For the drawer height, a common approach is height: 100dvh with a fallback to 100vh. If your CSS pipeline supports it, you can progressively enhance.

Second joke (and last, per policy and per my own dignity): Mobile Safari is the only place where “works on my phone” is not a reassuring statement.

Focus handling and accessibility: trap, restore, and escape

Docs users include keyboard users, screen reader users, and people who simply prefer not to touch glass all day. If your drawer breaks focus, it breaks the site for them.

The minimum viable accessibility contract

  • The toggle button has an accessible name (e.g., “Open navigation”).
  • The toggle reflects state using aria-expanded="true/false".
  • The drawer has a semantic container: usually nav or a div with an accessible label.
  • When opened, focus moves into the drawer (typically to the first focusable element or a close button).
  • Focus is trapped inside the drawer while open.
  • When closed, focus returns to the toggle button.
  • Esc closes the drawer.

Use inert where you can

Setting the rest of the page to inert when the drawer is open is the cleanest way to prevent background focus and clicks. It’s not magic: you still need to manage scroll lock, and you still need a visible overlay for taps. But inert is a huge reduction in weird focus edge cases.

If you can’t rely on inert, you can approximate it by:

  • Adding aria-hidden="true" to main content while drawer is open (but be careful: hiding too much can remove useful context for assistive tech).
  • Using a focus trap implementation that cycles focus within the drawer.
  • Disabling pointer events on main content (pointer-events: none) while overlay is active.

Focus traps: the three rules that prevent 90% of bugs

  1. Trap must activate after the drawer is in the DOM and visible. Otherwise the first Tab can escape.
  2. Trap must deactivate even if the drawer is closed via routing. Route changes are where traps go to rot.
  3. Always restore focus. Users rely on it. Also, it’s a great canary for “did our close logic actually run?”

Reduced motion is not a “nice to have”

If the user prefers reduced motion, do not slide a full-screen panel across the viewport. Swap to a near-instant transform or a fade. Your drawer’s animation is decorative; the navigation is the product.

Routing, hydration, and lifecycle: closing the drawer at the right times

Docs sites often run as SPAs or hybrid apps. That changes how drawers fail:

  • Hydration gap: HTML renders, user taps menu quickly, JS handlers aren’t attached yet. Result: nothing happens, or the page scrolls.
  • Route transitions: navigation click changes page content but leaves global UI in inconsistent state (drawer open, body locked).
  • Scroll restoration: frameworks sometimes restore scroll on route change, fighting your drawer’s scroll lock restore logic.

Rules that keep you sane

  • Close on route change by subscribing to router events. This is non-negotiable.
  • Close on breakpoint change: when switching from mobile to desktop nav layout, force-close and unlock scroll.
  • Be defensive on unmount: if the component unmounts while open, cleanup must still restore body styles and focus state.

Hydration: avoid “dead button” syndrome

For static-rendered docs, consider:

  • Rendering the toggle button as a real <button> with minimal inline script to open/close even before full hydration (if your platform allows).
  • Or delaying display of the toggle until JS is ready (less ideal; it hides navigation briefly).

Opinionated take: if your docs are your product’s front door, don’t ship a drawer that depends on a 300KB hydration payload to function.

Performance: what not to animate, and why blur is a tax

Drawers are performance traps because they look simple. They’re not. The typical drawer touches layout, compositing, scrolling, and event handling at the same time.

Animate transforms, not layout

Use transform: translateX() for the panel, not left or width. Layout-based animations can cause repeated reflow and repaint across the whole page—especially painful on long docs with code blocks and syntax highlighting.

Backdrop blur: treat it like a production dependency

backdrop-filter: blur() looks great. It also forces the browser to constantly re-rasterize what’s behind the overlay. On some devices, that’s not “slightly slower.” That’s “frames drop below 30fps and the UI feels broken.”

If you must use blur:

  • Make it conditional on reduced transparency/motion preferences.
  • Limit the blur radius.
  • Test on mid-tier Android and older iPhones, not just your laptop.

Event listeners: don’t leak, don’t duplicate

Drawer code often adds keydown and touchmove listeners. If you attach them on every open and forget to clean up, you’ll end up with multiple handlers firing. Symptoms include double-closing, jitter, and bizarre CPU spikes. In production, this looks like “the docs site gets worse the longer you use it.”

Three corporate mini-stories from the trenches

Incident: the wrong assumption (“overflow: hidden is enough”)

A team shipped a refreshed docs experience with a slick slide-in drawer. Desktop was perfect. Android Chrome was fine. The team congratulated itself and moved on to the next quarter’s goals, which is how you invite chaos.

Within a day, support started receiving reports: “Menu opens, but the page behind it scrolls. Then closing the menu jumps me somewhere else.” The reports were mostly iPhone users, but not all. The team’s first assumption was that it was a CSS z-index issue, because that’s the drawer bug everyone knows.

The actual root cause was a single line: body { overflow: hidden; } toggled on open. It “worked” on their test devices and failed on others due to differences in scroll container behavior and address bar dynamics. Some users had the drawer open while the page underneath rubber-banded; others saw the body unlock at odd times because the route changed and the component unmounted without cleanup.

Fixing it required two changes: (1) switch to the fixed-body-with-saved-scrollY lock, and (2) implement a global cleanup on unmount and on route change. The fun part: once they fixed scroll lock, a hidden focus bug surfaced because background elements were still tabbable. The drawer had been relying on “users are touching the screen,” which is not a focus strategy.

The incident wasn’t catastrophic, but it was reputational. Docs are where users go when they’re already frustrated. Breaking navigation on mobile is like locking the emergency exit and acting surprised.

Optimization that backfired: “GPU-accelerate everything”

Another org wanted their docs to feel “native.” Someone added transform: translateZ(0) and will-change: transform to the header and several layout wrappers to “improve scrolling performance.” The drawer overlay and panel were nested inside one of those wrappers, because that’s how the component tree happened to be structured.

It looked smooth in the happy path. Then users reported that tapping outside the drawer didn’t close it—sometimes. Some taps went through the overlay to links behind it. On certain pages, the overlay didn’t cover the header at all. It also broke screen capture tests because pixel composition differed between runs.

The root cause: the “optimization” created a new stacking context and compositing layer behavior that changed hit testing order. The overlay’s z-index was high inside its context, but the context itself sat under the fixed header’s separate context. In a few browsers, the compositor treated the overlay as visually on top but still allowed pointer events to pass to the header. That is a special kind of cursed.

The rollback removed most of the will-change hints. The actual performance improvement came later, from reducing DOM weight in the nav tree and deferring syntax highlighting until idle. The drawer animation itself was never the bottleneck; the page was.

Boring but correct practice that saved the day: a cleanup contract

A third team had a strict rule: any component that mutates global state must provide an explicit cleanup path, tested by automation. The drawer mutated global state: body styles for scroll lock, inert/aria-hidden for main content, and document-level key handlers.

They wrote a small “UI lock” module that owned these mutations. Components could request a lock with a token; releasing the token restored the prior state only when the last token was released. It wasn’t fancy. It was, however, resistant to re-entrancy and route-change unmounts.

Months later, a router upgrade changed the timing of route transition events. In other teams, drawers started leaving the body locked. On this team, the cleanup contract still ran because it was wired to both unmount and route completion events, and because their E2E test asserted that after navigation the body had no fixed positioning and that focus landed in the content.

Nobody wrote a celebratory email about it. Of course not. Reliable UI is like reliable storage: if people notice it, something has already gone wrong.

Fast diagnosis playbook

When the drawer is broken in production, you want a fast path to the bottleneck. Here’s the order that minimizes time-wasting.

1) Is it a stacking / overlay problem?

  • Check: Does the overlay visually cover everything? Does it intercept taps?
  • Signal: If taps go through, or header sits above overlay, suspect stacking contexts and portal placement.
  • Immediate fix direction: Move overlay/panel to a root portal; remove transform/filter from ancestors; standardize z-index layers.

2) Is it a scroll lock problem?

  • Check: With drawer open, can you scroll the page behind it? Does closing jump the scroll position?
  • Signal: Jump-to-top after close is classic “overflow hidden” or “height:100%” body lock mismatch.
  • Immediate fix direction: Switch to fixed-body lock with saved scroll position; ensure cleanup on every close path.

3) Is it a focus / keyboard problem?

  • Check: With a keyboard, does Tab escape? Does Esc close? Does focus return to toggle after close?
  • Signal: Focus escaping usually means trap not activated early enough or background not inert.
  • Immediate fix direction: Add inert or aria-hidden; implement a reliable focus trap; restore focus explicitly.

4) Is it performance / jank?

  • Check: Does opening the drawer drop frames or freeze? Does CPU spike?
  • Signal: Backdrop blur, large nav DOM, forced reflow, or event listener duplication.
  • Immediate fix direction: Remove blur, animate transform only, reduce DOM, audit event listeners and reflows.

Common mistakes: symptom → root cause → fix

Overlay doesn’t cover the header

Symptom: The fixed header stays clickable/visible above the dimmed background.
Root cause: Overlay is inside a lower stacking context; header created a separate stacking context via transform/filter or z-index rules.
Fix: Render overlay/panel via portal at end of body; remove stacking-context-creating CSS from layout wrappers; define a z-index scale.

Taps “leak” through the overlay

Symptom: Clicking outside the drawer triggers links behind it.
Root cause: Overlay has pointer-events: none, or it doesn’t span the viewport, or compositing/hit-testing mismatch from transforms.
Fix: Ensure overlay is position: fixed; inset: 0; with pointer events enabled; avoid nesting inside transformed parents.

Background scrolls under the open drawer

Symptom: You can scroll the page while drawer is open; rubber-band effect appears.
Root cause: Using only overflow: hidden or locking the wrong element; scroll chaining from drawer scroll container to body.
Fix: Use fixed-body scroll lock with saved scroll position; set overscroll-behavior: contain on drawer scroll area.

Closing the drawer jumps the page to the top

Symptom: Close drawer and your scroll position resets or shifts.
Root cause: Body was set to position: fixed without restoring scroll; or scroll restoration fighting your logic; or you changed html/body height.
Fix: Save scrollY on open; set top: -scrollY; on close, remove styles and call window.scrollTo(0, savedY). Integrate with router scroll restoration.

Keyboard focus escapes into the page behind

Symptom: Press Tab and focus goes to links in the main content, not the drawer.
Root cause: No focus trap, or trap activates too late, or background still focusable.
Fix: Use a focus trap and activate it immediately on open; set inert on main content; restore focus on close.

Esc closes the drawer sometimes, but not always

Symptom: Esc works once, then stops, or works only on certain pages.
Root cause: Keydown listener attached to a component that unmounts; duplicate listeners; focus is in an iframe/code block capturing keys.
Fix: Attach keydown at document level during open; ensure cleanup; ignore Esc if composing IME; handle focus inside embedded widgets.

Drawer opens, but screen reader announces nonsense

Symptom: Screen reader doesn’t announce navigation, or reads background content while drawer is open.
Root cause: Missing labels, misuse of aria-hidden, no focus management, or background not inert.
Fix: Label the nav; move focus into it on open; inert or hide background appropriately; ensure close button is reachable and named.

Opening the drawer is janky

Symptom: Frame drops, lag on touch, delayed animation start.
Root cause: Animating layout properties; heavy backdrop blur; forcing synchronous layout via JS reads/writes; large DOM in nav tree.
Fix: Animate transforms; remove blur or reduce radius; batch DOM reads/writes; virtualize or collapse nav sections.

Production-grade tasks with commands: check, measure, decide

UI bugs show up as “front-end problems,” but diagnosing them benefits from the same discipline you use for storage and reliability: measure first, change second, verify third. Below are practical tasks you can run from a shell while reproducing issues in a staging or production-like environment.

Assumptions: you have access to a test host running the docs site, logs, and optionally a headless browser environment. Commands are realistic and runnable; adapt file paths and service names.

Task 1: Confirm which HTML is shipped (SSR vs client-only)

cr0x@server:~$ curl -sS -D- https://docs.internal.example/guide/install | head -n 30
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate
x-powered-by: app
...
<!doctype html>
<html lang="en">
...

What the output means: You’re checking response headers and whether HTML content is delivered. If you see mostly empty HTML with a large script bundle reference and no nav markup, your drawer depends on hydration.
Decision: If the drawer is hydration-dependent, prioritize “no dead button” mitigation (inline minimal script or server-rendered nav shell).

Task 2: Verify caching isn’t serving mismatched JS/CSS versions

cr0x@server:~$ curl -sSI https://docs.internal.example/assets/app.css | egrep -i 'cache-control|etag|last-modified'
cache-control: public, max-age=31536000, immutable
etag: "a9f4c2-18b10"
last-modified: Tue, 10 Dec 2024 18:22:11 GMT

What the output means: Long-lived caching is fine only if assets are content-hashed and HTML references the correct versions.
Decision: If the HTML points to non-hashed assets with long cache lifetimes, you can get “drawer JS doesn’t match HTML/CSS.” Fix cache headers or asset versioning.

Task 3: Inspect Content Security Policy impacting inline scripts used for early drawer interactivity

cr0x@server:~$ curl -sSI https://docs.internal.example/ | egrep -i 'content-security-policy'
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

What the output means: If you plan to use tiny inline scripts to avoid hydration gaps, CSP may block them.
Decision: Either keep drawer behavior fully in bundled JS or update CSP with a nonce-based policy. Don’t “just add unsafe-inline.”

Task 4: Find error spikes tied to drawer interactions (client errors ingested server-side)

cr0x@server:~$ sudo journalctl -u docs-web -S "2 hours ago" | egrep -i 'TypeError|Unhandled|focus|inert|scroll' | tail -n 20
Dec 29 12:11:04 web-01 docs-web[2410]: UnhandledRejection: TypeError: Cannot read properties of null (reading 'focus')
Dec 29 12:14:19 web-01 docs-web[2410]: TypeError: Failed to execute 'setAttribute' on 'HTMLElement': 'inert' is not a valid attribute name

What the output means: Focus restoration bugs and inert misusage can throw exceptions, which can cascade into “drawer won’t close” because cleanup never runs.
Decision: Treat these as reliability issues: add guards, ensure cleanup in finally blocks, and feature-detect inert properly.

Task 5: Confirm that the overlay element exists and is not being stripped/minified incorrectly

cr0x@server:~$ curl -sS https://docs.internal.example/ | grep -n 'data-testid="nav-overlay"' | head
184:    <div data-testid="nav-overlay" class="overlay" hidden></div>

What the output means: You’re verifying the overlay is actually in the DOM as shipped (SSR) or at least present in the template.
Decision: If it’s missing, debugging CSS won’t help. Fix rendering/templating first.

Task 6: Check for unexpectedly large JS bundles that delay hydration

cr0x@server:~$ curl -sSI https://docs.internal.example/assets/app.js | egrep -i 'content-length|content-encoding'
content-encoding: br
content-length: 612341

What the output means: A ~600KB brotli bundle can still be heavy on mobile, especially with parse/compile cost.
Decision: If drawer functionality waits for this bundle, split critical UI, defer non-critical scripts, and avoid making nav the hostage of analytics.

Task 7: Validate server compression for CSS/JS (slow transfer = longer “dead button” window)

cr0x@server:~$ curl -sSI -H 'Accept-Encoding: gzip, br' https://docs.internal.example/assets/app.js | egrep -i 'content-encoding|vary'
vary: Accept-Encoding
content-encoding: br

What the output means: Compression is enabled and varies correctly per encoding.
Decision: If compression is missing, fix that before redesigning the drawer. Latency is a feature flag you forgot to add.

Task 8: Verify the drawer routes are closing the drawer (server logs for SPA navigation won’t help; use synthetic checks)

cr0x@server:~$ node -e "console.log('Run an E2E check here with Playwright/Cypress in CI; server logs cannot see client route changes.')"
Run an E2E check here with Playwright/Cypress in CI; server logs cannot see client route changes.

What the output means: This is a blunt reminder: you can’t diagnose client-only state bugs from server access logs alone.
Decision: Add a synthetic browser check that opens drawer, clicks a nav link, asserts body scroll is unlocked and focus lands in content.

Task 9: Look for NGINX misconfiguration that breaks range requests (hurts performance on mobile)

cr0x@server:~$ curl -sSI https://docs.internal.example/assets/app.js | egrep -i 'accept-ranges'
accept-ranges: bytes

What the output means: Range requests can help some clients and CDNs. Not a drawer bug, but it affects time-to-interactive, which affects drawer responsiveness.
Decision: If missing, review static asset serving configuration. Performance regressions present as “the menu is broken” because users tap before handlers exist.

Task 10: Confirm response time and tail latency during drawer-heavy pages (nav tree pages)

cr0x@server:~$ curl -sS -w "ttfb=%{time_starttransfer} total=%{time_total}\n" -o /dev/null https://docs.internal.example/guide/reference
ttfb=0.142315 total=0.211904

What the output means: If TTFB is high, your HTML arrives late. If total time is high, your network path is slow. Both increase “UI feels dead.”
Decision: If TTFB spikes, fix backend/render caching. If network is slow, improve CDN, compression, and asset strategy.

Task 11: Watch for memory pressure on the server causing slow responses (it’s not always the front-end)

cr0x@server:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:           31Gi        26Gi       1.2Gi       352Mi       3.9Gi       4.3Gi
Swap:          2.0Gi       1.8Gi       256Mi

What the output means: Low available memory and swap usage can lead to latency spikes serving HTML/JS, delaying interactivity.
Decision: If swapping, fix memory pressure before blaming CSS. Users don’t care where the bug lives.

Task 12: Identify CPU saturation during peak docs usage (again: “menu unresponsive” can be server-side)

cr0x@server:~$ uptime
 12:29:44 up 18 days,  4:12,  2 users,  load average: 6.21, 6.02, 5.88

What the output means: High load average relative to CPU cores can cause slow HTML and delayed JS delivery.
Decision: If load is consistently high, scale out, cache, or reduce render cost. Then re-test drawer “responsiveness.”

Task 13: Verify static asset sizes on disk to spot accidental debug builds

cr0x@server:~$ ls -lh /var/www/docs/assets | egrep 'app\.(js|css)' | head -n 5
-rw-r--r-- 1 www-data www-data 5.9M Dec 10 18:22 app.js
-rw-r--r-- 1 www-data www-data 412K Dec 10 18:22 app.css

What the output means: If app.js is multi-megabytes uncompressed, you may be shipping source maps or dev builds to production.
Decision: Fix build pipeline. Drawer bugs multiply when the client is struggling to parse your JavaScript novel.

Task 14: Check if error pages or auth redirects are being cached (drawer “breaks” because the page isn’t the page)

cr0x@server:~$ curl -sSI https://docs.internal.example/guide/install | egrep -i 'http/|location:|cache-control:'
HTTP/2 200
cache-control: public, max-age=0, must-revalidate

What the output means: If you get redirects or unexpected cache headers, clients might be seeing stale or partial HTML (missing nav scripts).
Decision: Ensure correct caching behavior for HTML and correct redirect handling. Drawer JS missing because you served a login interstitial is still a drawer outage.

Task 15: Validate that your service worker (if any) isn’t serving stale shell HTML

cr0x@server:~$ grep -R "workbox" -n /var/www/docs/ | head
/var/www/docs/sw.js:12:importScripts('workbox-*.js');

What the output means: A service worker can cache the app shell aggressively and serve mismatched HTML/JS across releases.
Decision: If you use a service worker, implement proper versioning and cache invalidation. Otherwise you’ll debug “random” drawer regressions that are actually stale clients.

Task 16: Look for duplicated event handler attachment in the built bundle (quick grep)

cr0x@server:~$ grep -R "addEventListener(\"keydown\"" -n /var/www/docs/assets/app.js | head
12877:document.addEventListener("keydown",u)

What the output means: Not proof of leaks, but it tells you where key handlers are wired. If your app adds listeners on every open without removing them, you’ll see multiple calls at runtime.
Decision: Audit lifecycle and cleanup. Add instrumentation counters if needed. UI event leaks are the cousin of file descriptor leaks: ignored until they bite.

Checklists / step-by-step plan

Step-by-step implementation plan (the boring version that works)

  1. Place overlay and drawer in a top-level UI layer appended to body (portal). Avoid transformed ancestors.
  2. Create a z-index scale and enforce it in code review. If someone adds z-index: 999999, make them explain their life choices.
  3. Implement scroll lock using saved scrollY + fixed body. Include cleanup on every exit path.
  4. Make the drawer panel its own scroll container with overscroll containment.
  5. Implement focus management: save active element, move focus into drawer on open, trap focus, restore on close.
  6. Implement close mechanisms: overlay click, close button, Esc, route change, breakpoint change.
  7. Add reduced motion support to avoid motion-heavy transitions.
  8. Test on iOS Safari with long pages and a deep scroll position. Do not accept “works in Chrome emulator.”
  9. Add E2E checks verifying scroll lock restore and focus restore across navigation.
  10. Instrument errors from focus/scroll code paths; treat them like availability issues.

Pre-merge checklist (what to demand in review)

  • Overlay is position: fixed with full-viewport coverage.
  • No overlay/drawer inside transformed layout wrapper.
  • Body lock stores and restores scroll position deterministically.
  • All close paths call the same cleanup function.
  • Focus is restored to the toggle button after close.
  • Drawer has a close button reachable by keyboard.
  • Esc closes; Tab does not escape.
  • Reduced motion is respected.
  • Route change closes drawer and unlocks scroll.
  • No blur/backdrop filter without performance testing sign-off.

Release checklist (production reality)

  • Check cache headers: HTML not overly cached; assets immutable with hashes.
  • Verify bundle size budgets didn’t regress time-to-interactive.
  • Run synthetic mobile nav test in CI and after deploy.
  • Monitor client error ingestion for focus/scroll exceptions.
  • Have a rollback path that also invalidates service worker cache if used.

FAQ

1) Should a docs navigation drawer be a <dialog>?

Usually no. Treat it like a modal in behavior, but semantically it’s navigation. Use a nav container, overlay, and focus trapping. <dialog> can work, but it introduces its own quirks and styling constraints.

2) Is inert safe to use?

It’s broadly usable now, but still feature-detect and test. If you can’t rely on it everywhere you care about, fall back to a focus trap plus careful aria management. Don’t ship something that blocks clicks but still allows background focus.

3) Why does my page jump when I close the drawer?

Because your scroll lock didn’t restore the scroll position correctly, or the framework’s scroll restoration fought you. Save scrollY on open, lock body using fixed positioning with top: -scrollY, then restore scrollY on close and coordinate with router scroll behavior.

4) Do I really need to trap focus for a nav drawer?

Yes, if it behaves like an overlay that blocks the page. Otherwise keyboard users can tab into invisible or obscured content behind the drawer. That’s not “slightly annoying,” it’s broken.

5) Why does the overlay sit under some elements even with high z-index?

Because z-index is scoped to stacking contexts. If your overlay lives inside a stacking context that’s below a header’s stacking context, it can’t out-z-index it. Fix the DOM placement (portal) and remove stacking context triggers from ancestors.

6) Should the drawer close when a nav link is clicked?

Yes. Always. Close immediately on click (optimistically), then let routing happen. Also close on route change events in case navigation occurs via other means (programmatic, back/forward, hash changes).

7) Is backdrop blur worth it?

Only if you can prove it doesn’t tank performance on representative devices. Blur is a constant cost during animation and while open. A simple semi-transparent overlay is cheaper and more predictable.

8) How do I handle nested nav trees without making the drawer unusable?

Use progressive disclosure: collapse sections by default, preserve the user’s expanded state per session, and keep the active page’s path expanded. And keep the nav DOM lighter than your ego—deep trees can be expensive to render and scroll.

9) What about swipe gestures to open/close the drawer?

Be careful. Gestures conflict with browser navigation and scroll. If you implement swipe, make it opt-in and keep it secondary to explicit controls. Your primary job is “open reliably,” not “feel like a native app demo.”

10) How do I test this properly?

Run E2E tests that assert: overlay covers viewport, background doesn’t scroll, focus moves into drawer, Tab stays inside, Esc closes, focus returns to toggle, and route change unlocks scroll. Then manually test on iOS Safari with a long page scrolled deep.

Conclusion: next steps that survive production

A mobile docs drawer is not a design flourish. It’s infrastructure. It determines whether users can escape a page, find the right guide, and keep their place while they’re debugging something at 2 a.m.—which, incidentally, is also when you’ll be debugging your drawer if you ship it sloppy.

Do this next:

  1. Move overlay/drawer to a top-level portal layer and standardize z-index.
  2. Implement fixed-body scroll lock with saved scroll position and bulletproof cleanup.
  3. Add inert (or an equivalent) and a real focus trap; restore focus on close.
  4. Close on route change and breakpoint change—every time.
  5. Run one synthetic E2E test that fails loudly when body remains locked or focus escapes.

Ship the boring drawer. Your users will never thank you, which is how you’ll know it worked.

← Previous
Windows Phone: How a Good OS Lost to Ecosystems
Next →
WordPress Stuck in Maintenance Mode: Remove It Safely and Prevent Repeats

Leave a comment