A curated collection of articles exploring this topic in depth.
The Logic of Interaction
Transitioning from visual to programmatic state and the mechanics of focus management in interactive components.
In the first part of this series, I explored the "invisible scaffolding" of a site: the structural landmarks and bypass links that define the Accessibility Tree. However, a website is rarely static. The moment we introduce interactive elements, like a mobile navigation menu, the challenge shifts from structure to logic.
Context: Visual vs. Programmatic State
When a user clicks a menu button, the visual state is obvious: the menu slides into view. But for a user who cannot see the screen, that visual transition is silent. If the Accessibility Tree does not reflect this change, the user is left wondering if their action had any effect.
It is one thing to make a menu appear on the screen; it is another to ensure the browser, and by extension the user, understands that the state of the application has changed. This is where accessibility literacy becomes about logic.
Approach: Communicating State and Focus
Given my preference for minimal dependencies in a static export environment, I handle focus management directly rather than reaching for third-party libraries. Making an interactive component accessible requires explicitly communicating state and focus to the browser through ARIA states and focus management.
1. Communicating Intent with ARIA
The first step was to wire the toggle button to the menu it controls. By using aria-expanded, we tell the browser whether the controlled element is currently open or closed. Pairing this with aria-controls creates a programmatic link between the button and the menu.
// web/components/mobile-nav.tsx
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label={isOpen ? "Close menu" : "Open menu"}
>
{/* icon */}
</button>
<div id="mobile-menu" role="navigation">
{/* navigation links */}
</div>The insight here was realising that aria-expanded is not just a label; it changes how the button is perceived in the Accessibility Tree. When the state changes, the browser announces the new state, providing immediate feedback that the action was successful.
2. The Emergency Exit: The Escape Key
For a sighted user, closing a menu is often as simple as clicking a backdrop or a close button. For a keyboard user, there is a well-known "emergency exit": the Escape key.
Implementing this ensured that the user is never "trapped" in a state they cannot easily exit. We use a global listener that only triggers when the menu is open:
// web/components/mobile-nav.tsx
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
setIsOpen(false);
toggleButtonRef.current?.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);3. Focus Management: Return to Origin
One of the most common accessibility regressions is "losing the focus." When the menu closes, the focus should not just disappear; it should return to the element that triggered the interaction.
In this implementation, we keep a reference to the toggle button and programmatically focus it when the menu closes. Without this, a keyboard user would be forced to re-tab through the entire page just to find where they left off.
4. Client-Side Navigation: Resetting the Starting Point
In a traditional multi-page site, every navigation resets the browser's focus to the top of the document. The skip link is always the first tab stop. However, in a Next.js App Router, client-side navigation does not trigger a full page load. The browser remembers where focus was before the navigation, so pressing Tab continues from that position rather than starting from the top.
This means that after clicking a link, a keyboard user can no longer reach the skip link without manually tabbing through the entire page. To solve this, I implemented a RouteFocusReset component that listens for pathname changes and programmatically resets the focus starting point:
// web/components/route-focus-reset.tsx
"use client";
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function RouteFocusReset() {
const pathname = usePathname();
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
document.body.setAttribute('tabindex', '-1');
document.body.focus();
document.body.removeAttribute('tabindex');
}, [pathname]);
return null;
}Simply calling document.body.blur() is insufficient because the browser remembers the last focus position and Tab continues from there. By temporarily making the body focusable and focusing it, we reset the sequential focus navigation starting point so the next Tab press reaches the skip link.
Trade-offs: Choosing role="navigation"
A common recommendation for mobile menus is to use role="dialog" and aria-modal="true". However, doing so correctly requires a robust focus trap to ensure the user cannot tab out of the menu into the background.
Given my preference for avoiding heavy libraries, I made a pragmatic choice: I used role="navigation" instead.
By using a standard navigation landmark, I avoided the complex requirements of a modal whilst still providing a clear, labelled area for the menu links. This trade-off prioritises reliability and standard browser behaviour over a more complex ARIA implementation that might be prone to bugs if managed manually.
Literacy Gained
Working through these patterns has changed how I think about JavaScript in the browser. The visual side of an interaction (sliding a menu into view, swapping an icon) is the easy part. The real work is ensuring the Accessibility Tree stays in sync: announcing state changes, returning focus to where the user left off, and resetting the navigation starting point after a client-side route change.
Managing this manually has also given me a deeper appreciation for the work that accessibility libraries do. What felt like a small set of requirements (an Escape key handler, a focus return, a route reset) quickly became a web of edge cases that need to be kept consistent across every interactive component.
In the next part, we will look at how we can automate these patterns by building them directly into our MDX content pipeline.