{"id":"l85ppx6lzzitchs","title":"Skip Links and Keyboard Nav on SPAs","slug":"skip-links-keyboard-nav-spa","summary":"React’s client-side routing breaks native skip links because the browser doesn't trigger a full page reload to reset focus, leaving keyboard users stuck in…","imageUrl":"https://briancrabtree.me/images/journal-skip-links-keyboard-nav-spa.webp","category":"HTML","date":"2026-02-10T18:00:00.000Z","featured":false,"likes":38,"author":"Brian Crabtree","content":"<h2>The First Time It Broke</h2>\n\n<p>I inherited a React application with a slick design but a completely broken keyboard experience. Hitting tab just bounced focus around the browser chrome, never touching the actual content. This immediate failure highlighted the critical need for proper skip links spa accessibility early on.</p>\n\n<p>The core problem was a heavily dynamic layout with modal dialogs and off-canvas menus, all implemented without standard HTML elements or proper aria attributes. Every time the route changed, focus got lost in the ether. Screen reader users would be forced to listen to the entire navigation menu again and again.</p>\n\n<p>My first reaction was to dive into useEffect hooks and useRef to manually manage focus. This turned into a nightmare of edge cases, especially with nested components and conditional rendering. It felt like I was fighting the browser's native focus management, not working with it.</p>\n\n<pre><code>&lt;a class=\"skip-link\" href=\"#main\"&gt;Skip to content&lt;/a&gt;\n...\n&lt;main id=\"main\" tabindex=\"-1\"&gt;</code></pre>\n\n<p><figure>\n  <img src=\"/images/journal-inline-skip-links-spa.webp\" alt=\"Keyboard navigation flow showing skip link jumping focus to main content\" width=\"1200\" height=\"675\" loading=\"lazy\" />\n  <figcaption>Skip links belong in the first HTML — not injected after hydration.</figcaption>\n</figure></p>\n\n<h2>Why Native Skip Links Don't Just Work</h2>\n\n<p>A traditional skip link is a simple anchor tag pointing to a main content ID. In a static HTML page, clicking it scrolls the view and moves browser focus to the target. This relies on the browser's default behavior, which a SPA often overrides or complicates.</p>\n\n<p>With client-side routing, the entire page usually doesn't reload. When you navigate, React or your framework might swap components, but the browser's \"page load\" event doesn't fire. This means the default focus management following an <a> tag with href=\"#id\" often fails to correctly set focus. The content might render, but the keyboard focus remains where it was.</p>\n\n<p>This led to a situation where the skip link visually worked—it scrolled to the main content—but assistive technology didn't register the focus change. A screen reader user would still be stuck at the top of the new page, forced to tab through the header again. We needed a JavaScript-driven solution to explicitly set focus after the route change.</p>\n\n<h2>The JavaScript Focus Hook</h2>\n\n<p>The fix involved a small utility hook to manage focus after a client-side navigation. On route change, we'd find the main content area (usually main element with an id), ensure it was focusable, and programmatically set focus to it. This explicit action bypassed the browser's default, often unreliable, behavior in a SPA context.</p>\n\n<p>We added tabIndex=\"-1\" to the main content element to make it programmatically focusable without adding it to the natural tab order. Then, after the content rendered following a route change, a useEffect in the layout component would call .focus() on that element. This ensured screen readers and keyboard users landed directly on the new page's primary content.</p>\n\n<p>The key was to ensure this focus management happened after the new content was fully mounted and visible in the DOM. Race conditions were common, where focus was set before the target element was ready. We wrapped the .focus() call in a requestAnimationFrame or a small setTimeout to defer it slightly, allowing the browser to catch up.</p>\n\n<h2>Implementing the Skip Link</h2>\n\n<p>Once the focus management was solid, creating the actual skip link was straightforward. We placed a hidden link at the very top of the page, visible only on keyboard focus (using :focus styles). Its href attribute still pointed to #main-content, but now we intercepted the click.</p>\n\n<p>On click, instead of relying purely on the default anchor behavior, our JavaScript function would explicitly call document.getElementById('main-content').focus(). This gave us control and consistency across different browsers and navigation patterns. The link text was \"Skip to main content,\" clearly communicating its purpose.</p>\n\n<p>The styling for :focus was critical here. It needed to be obvious when the link received focus, providing a clear visual indicator for sighted keyboard users. A simple border or background change was enough, ensuring it didn't disrupt the design until activated.</p>\n\n<h2>Dynamic Content and Focus Traps</h2>\n\n<p>Skip links solved the initial page load issue, but dynamic content, especially modal dialogs, introduced new challenges. When a modal opens, focus must be moved inside it, and keyboard navigation should be confined to its elements. This prevents users from tabbing behind the modal, losing context.</p>\n\n<p>We implemented a \"focus trap\" component that, when active, would intercept tab key presses and cycle focus only within its boundaries. When the modal closed, focus needed to return to the element that triggered it. This required careful state management and event listeners.</p>\n\n<p>Failure to manage focus traps correctly leads to accessibility violations and frustration. It's not just about trapping focus; it’s about restoring it gracefully. I've written about similar challenges with Web Components and Shadow DOM over at <a href=\"/journal/skip-links-keyboard-nav-spa/\">Skip Links and Keyboard Nav on SPAs</a> where focus management also requires explicit handling.</p>\n\n<h2>Testing with Real Tools</h2>\n\n<p>Manual keyboard testing is non-negotiable. Disconnect your mouse and navigate your entire application using only the tab key, arrow keys, and enter. Look for elements that are unreachable, focus outlines that disappear, or content that gets skipped unexpectedly. This often reveals issues automated tools miss.</p>\n\n<p>We used browser extensions like Axe DevTools and Lighthouse reports to catch common pitfalls. These tools provide actionable feedback on missing tabIndex, insufficient contrast, and general aria misconfigurations. They are great for a baseline, but they don't replace human testing.</p>\n\n<p>The most valuable feedback came from actual screen reader users. We involved a small group of testers who regularly use NVDA and VoiceOver. Their insights were blunt and practical, highlighting workflows we hadn't considered and revealing where our programmatic focus management still fell short.</p>\n\n<h2>What I Do Next</h2>\n\n<p>For every new SPA, I bake in proper skip link functionality and robust keyboard navigation from the start. It’s no longer an afterthought. The initial setup cost is minimal compared to the headache of retrofitting it into a complex application later.</p>\n\n<p>I also enforce a strict rule: if it's interactive, it must be keyboard focusable and have a visible focus indicator. This means custom buttons, links, and form elements all need a tabIndex if not naturally focusable, and clear :focus styles.</p>\n\n<p>Don't let your framework dictate poor accessibility. Take control of focus. For more on structuring content semantically and making it understandable for machines and humans alike, check out <a href=\"/journal/semantic-html-landmarks-beyond-divs/\">Semantic HTML Landmarks Search Engines Actually Read</a>.</p>","tags":["skip-links","keyboard","spa"],"views":129}