About eight months ago, a client came back to me two weeks after launch with a complaint I hadn't anticipated. The product was a membership portal — moderate complexity, mostly table-driven data, a sidebar nav on the left that collapsed into a hamburger menu on mobile. Desktop experience: fine. Mobile experience: users were getting stuck.
Not stuck in the sense of confused. Stuck in the literal sense. Some users on iOS — particularly those using VoiceOver — reported that once they opened the sidebar, they couldn't get back to the page content. One user described it as "the menu just traps me." Another said the sidebar wouldn't close when they tapped outside it. A third said the page was scrolling behind the open menu, which was disorienting enough that they gave up.
Three separate bugs. All related. All rooted in the same core mistake: I had built the sidebar thinking about visual state, not interaction state. This is a post-mortem on what I got wrong, what it cost, and exactly how I fixed it.
The Original Implementation (And Its Assumptions)
The sidebar was built in vanilla JS with a CSS transition — a fairly standard pattern. A toggle button set a data-open attribute on the sidebar element, CSS handled the transform from translateX(-100%) to translateX(0), and a semi-transparent overlay behind the sidebar could be tapped to close it. Clean, simple, visually solid.
Here's roughly what the toggle logic looked like at launch:
const sidebar = document.querySelector('.sidebar');
const overlay = document.querySelector('.sidebar-overlay');
const toggleBtn = document.querySelector('.sidebar-toggle');
toggleBtn.addEventListener('click', () => {
const isOpen = sidebar.dataset.open === 'true';
sidebar.dataset.open = isOpen ? 'false' : 'true';
overlay.classList.toggle('visible', !isOpen);
document.body.classList.toggle('sidebar-open', !isOpen);
});
overlay.addEventListener('click', () => {
sidebar.dataset.open = 'false';
overlay.classList.remove('visible');
document.body.classList.remove('sidebar-open');
});
And in the CSS, .sidebar-open on the body was supposed to prevent background scrolling:
.sidebar-open {
overflow: hidden;
}
On paper, this looks fine. In practice, it had three distinct failure modes.
Failure Mode 1: Background Scroll on iOS Safari
The overflow: hidden on body trick does not fully work on iOS Safari — and this is a known, maddening quirk. Safari allows the page to continue scrolling beneath a fixed overlay because it doesn't always respect overflow: hidden on the body the same way Chrome or Firefox does.
The symptom: users could open the sidebar, swipe on the overlay, and the page content behind it would scroll. Visually confusing, and it made the overlay feel broken because the tap target for dismissal seemed to move.
The fix required two additions. First, storing the current scroll position before locking, then restoring it afterward. Second, setting the body to position: fixed while the sidebar is open, which forces iOS Safari to actually respect the lock:
let scrollY = 0;
function lockScroll() {
scrollY = window.scrollY;
document.body.style.top = `-${scrollY}px`;
document.body.classList.add('sidebar-open');
}
function unlockScroll() {
document.body.classList.remove('sidebar-open');
document.body.style.top = '';
window.scrollTo(0, scrollY);
}
/* CSS */
.sidebar-open {
position: fixed;
overflow-y: scroll;
width: 100%;
}
This is the pattern that actually works cross-browser. The overflow-y: scroll keeps the scrollbar gutter visible so the layout doesn't jump. The top offset preserves scroll position across the lock/unlock cycle. Painful to discover the hard way, but now it's one of the first things I set up in any sidebar implementation.
Failure Mode 2: Focus Was Completely Unmanaged
This is the one that caused the "trap" experience for VoiceOver users, and honestly it embarrassed me once I saw what was actually happening.
When the sidebar opened, focus stayed wherever it was on the page — often behind the overlay. Screen reader users would try to navigate and find themselves reading content that was visually obscured, or cycling through interactive elements in the background instead of inside the sidebar.
When the sidebar closed, focus evaporated. It didn't return to the toggle button. It just... dropped. In VoiceOver, that meant the reading cursor jumped to an unpredictable place on the page, sometimes back to the top.
The fix was explicit focus management on both open and close:
function openSidebar() {
sidebar.dataset.open = 'true';
overlay.classList.add('visible');
lockScroll();
// Move focus into the sidebar
const firstFocusable = sidebar.querySelector(
'a, button, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) firstFocusable.focus();
}
function closeSidebar() {
sidebar.dataset.open = 'false';
overlay.classList.remove('visible');
unlockScroll();
// Restore focus to the toggle button
toggleBtn.focus();
}
That alone fixed the "trap" complaint for most users. But there was still a subtler problem: focus could still leave the sidebar while it was open, cycling back into the background content via Tab key. So I added a focus trap.
function trapFocus(element) {
const focusable = element.querySelectorAll(
'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
element.addEventListener('keydown', function handler(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// Clean up when sidebar closes
if (!sidebar.dataset.open || sidebar.dataset.open === 'false') {
element.removeEventListener('keydown', handler);
}
});
}
I call trapFocus(sidebar) immediately after openSidebar(). This is a minimal implementation — there are libraries like focus-trap that handle edge cases more robustly, but for a relatively simple sidebar nav, this covered everything I needed without adding a dependency.
Failure Mode 3: Missing ARIA Attributes on the Toggle Button
The third issue was quieter but equally real. The toggle button had no aria-expanded attribute and no aria-controls pointing at the sidebar. Screen readers had no way to communicate state to the user — whether the sidebar was open or closed was purely visual information.
This is a one-time fix, but it has to be kept in sync with the open/close state at runtime:
<!-- HTML -->
<button
class="sidebar-toggle"
aria-expanded="false"
aria-controls="main-sidebar"
aria-label="Open navigation menu"
>
<span class="hamburger-icon"></span>
</button>
<nav id="main-sidebar" class="sidebar" aria-label="Main navigation">
<!-- nav links -->
</nav>
// In JS, update aria-expanded on state change
function openSidebar() {
toggleBtn.setAttribute('aria-expanded', 'true');
toggleBtn.setAttribute('aria-label', 'Close navigation menu');
// ... rest of open logic
}
function closeSidebar() {
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.setAttribute('aria-label', 'Open navigation menu');
// ... rest of close logic
}
Small additions, but they mean the difference between a screen reader announcing "Open navigation menu, collapsed, button" versus just announcing "button." That context matters enormously to users who rely on it.
What the Post-Mortem Actually Taught Me
Looking back, the core mistake was treating the sidebar as a visual component. I tested it visually: does it slide in? Does it close when I tap the overlay? Yes. Ship it. What I didn't test was the interaction model — how a user with a keyboard or screen reader would actually move through it.
These are the specific habits I've changed since this incident:
- Test with VoiceOver on iOS and TalkBack on Android before any mobile nav goes to production. Not a full accessibility audit — just an actual walkthrough of opening and closing the nav with the screen reader on.
- Always write open/close as explicit paired functions, not a single toggle function. The paired structure forces me to think about what needs to happen in both directions, including focus and scroll state.
- iOS Safari scroll lock is a special case that always requires the fixed-position approach. I now keep a small scroll-lock utility as a snippet I paste in at project start.
- ARIA attributes on the toggle button are not optional.
aria-expanded,aria-controls, and a meaningfularia-labelthat updates on state change are baseline requirements, not enhancements.
Navigation patterns sit at the intersection of visual design, interaction design, and accessibility — and it's easy to get two of those three right and miss the third entirely. I've written before about how interaction state and visual state can quietly diverge in ways that create real UX problems; if you're working on anything with complex UI state, my post on search filter UX and state chaos covers some adjacent failure modes worth reading alongside this one.
I also want to be honest about the timeline here. The background scroll bug was found within the first week post-launch and fixed quickly. The focus management issues took longer to surface because they required users who were actually using VoiceOver to report them — and those users, understandably, sometimes just leave rather than file a bug report. That's the part that stings. You don't always know how many users you've already lost before the feedback arrives.
The Fixed Architecture at a Glance
After all the fixes, the sidebar now does the following, in order:
- On open: lock scroll (with iOS-compatible fixed-position approach), update
data-open, updatearia-expandedandaria-label, move focus to first focusable element inside sidebar, activate focus trap. - On close (from any source — button, overlay tap, Escape key): unlock scroll (restoring position), update
data-open, updatearia-expandedandaria-label, return focus to toggle button. - Escape key closes the sidebar from anywhere inside it — a behaviour users expect and that I had originally omitted.
Adding Escape key support is two lines:
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebar.dataset.open === 'true') {
closeSidebar();
}
});
The finished implementation is about 80 lines of vanilla JS. Nothing exotic. But each of those 80 lines exists because of a specific failure I encountered, not because I planned it correctly the first time.
Navigation is one of those UI patterns that looks solved. Hamburger menus have existed forever, sidebar patterns are everywhere, every CSS framework has one. But "widely used" and "correctly implemented" are not the same thing — and on mobile, where the interaction model is fundamentally different from desktop, the gap between those two things can swallow real users.
Frequently Asked Questions
Why does focus management matter so much for sidebar navigation on mobile?
On mobile, keyboard and screen reader users — including those using switch access or external keyboards — can get trapped inside an open sidebar if focus isn't explicitly controlled. Without managing focus on open and restore on close, users lose their place in the page entirely, which is a real accessibility failure, not just a cosmetic one.
What's the simplest way to prevent background scrolling when a mobile sidebar is open?
Setting overflow: hidden on the body element when the sidebar opens is the most reliable baseline approach. The tricky part is remembering to restore the scroll position accurately when the sidebar closes — especially on iOS Safari, which handles fixed positioning and scroll differently from other browsers.
Should a sidebar overlay be a <nav>, a <dialog>, or something else semantically?
For a full-screen or large overlay sidebar, <dialog> with role="dialog" and aria-modal="true" is often the right call because browsers and screen readers understand its open/close semantics natively. For a persistent or partially-visible sidebar, <nav> with appropriate aria-expanded and aria-controls attributes on the toggle button is more appropriate.