Fixing Skip Links That Scroll to the Target But Don't Actually Move Keyboard Focus
A "Skip to main content" link is present, correctly the first focusable element on the page, and activating it visibly scrolls the page down to the main content area. For a sighted keyboard user this looks like it worked — but the next Tab press continues from wherever focus was before, not from the content that just scrolled into view, because scrolling the page and moving keyboard focus are two completely separate things.
The Problem
A "Skip to main content" link — the standard pattern for letting keyboard users bypass a long navigation menu — is implemented, visually hidden until focused, and correctly the first tabbable element on the page. Pressing Enter on it visibly scrolls the viewport down to the main content section. From a purely visual standpoint, it looks like it's working exactly as intended. But continuing to press Tab afterward moves focus starting from the page's navigation again, not from inside the main content area, meaning a keyboard-only or screen-reader user gets no actual benefit from the skip link — they still have to tab through everything it was supposed to let them bypass.
Why It Happens
A plain anchor link to an id scrolls the page, but browsers only move keyboard focus if the target is itself focusable
An <a href="#main-content"> link activating does reliably scroll the target element into view — that part of the browser's native fragment-navigation behavior works regardless of what the target element is. Moving actual keyboard focus to that target, however, only happens if the element being scrolled to is itself a focusable element. A <div id="main-content"> or a <main> element is not focusable by default, so the browser scrolls to it but has nothing there to actually place focus on.
Non-interactive elements like <main> or a generic <div> aren't part of the keyboard's natural tab order at all
Only a specific set of elements — links, buttons, form fields, and anything with an explicit tabindex — participate in keyboard focus by default. Landmark and sectioning elements like <main>, which are exactly the kind of element a skip link typically targets, are deliberately not part of that set, because they're meant to be structural containers rather than interactive controls.
The visible scroll makes the bug easy to miss during casual visual testing, since nothing looks broken on screen
Because the page clearly does scroll, and the link clearly is focusable and activatable, a quick visual check of the skip link gives every impression that it's functioning correctly. The actual defect — that keyboard focus itself never moved — only becomes apparent by specifically continuing to tab afterward and checking where focus lands, which is a step a purely visual QA pass is easy to skip.
The Fix
1. Make the skip link's target programmatically focusable with tabindex="-1"
<a href="#main-content">Skip to main content</a>
<!-- ... -->
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
tabindex="-1" makes an element programmatically focusable — reachable via JavaScript's .focus() or as the target of fragment navigation — without adding it to the normal sequential tab order, which is exactly the behavior needed here: the main content container should be a valid focus target for the skip link, but shouldn't itself become a new stop every time a user tabs through the page normally.
2. Verify that fragment navigation to a tabindex="-1" target actually moves focus in every target browser, since support has historically been inconsistent
// A defensive fallback that explicitly calls focus(), for browsers where
// fragment navigation alone doesn't reliably move focus to a tabindex="-1" element:
document.querySelector('a[href="#main-content"]').addEventListener("click", () => {
requestAnimationFrame(() => {
document.getElementById("main-content")?.focus();
});
});
While modern browsers generally handle this correctly, explicitly calling .focus() on the target after the click — rather than relying solely on native fragment-navigation behavior — removes any doubt about cross-browser consistency and makes the fix's correctness independent of a specific browser's current implementation.
3. Test the fix specifically by pressing Tab again after activating the skip link, not just by checking that the page visibly scrolled
Activate the skip link, then press Tab once more and confirm the next
focused element is the first interactive element inside the main content
area — not back in the navigation, and not the browser's address bar.
Checking where the very next Tab press lands is the test that actually exercises the thing the skip link is supposed to do — a visual check that the page scrolled, on its own, can pass even when focus itself never moved, which is exactly the gap that produces this bug in the first place.
4. Consider moving focus to a visually hidden heading just before the main content if styling tabindex="-1" directly on a large container feels awkward
<a href="#main-heading">Skip to main content</a>
<!-- ... -->
<h2 id="main-heading" tabindex="-1" class="visually-hidden">Main content</h2>
<main>
<!-- page content -->
</main>
A small, visually hidden heading placed right before the main content gives a clean, dedicated focus target without needing tabindex="-1" on a large structural element some teams prefer to leave untouched, while still producing the exact same effective result for the keyboard user.
Why This Works
Each fix addresses the same core gap between two distinct browser behaviors that a skip link's visible scrolling can easily be mistaken for having already solved. Adding tabindex="-1" to the target is the direct fix, making an otherwise non-focusable landmark a valid place for focus to actually land. Explicitly calling .focus() as a fallback removes dependence on any particular browser's native handling of that case. Testing by continuing to tab, rather than just confirming the scroll happened, is what actually verifies the thing a skip link exists to provide. And a dedicated hidden heading offers the same fix without altering a structural element some projects prefer to keep plain.
Conclusion
A skip link that visibly scrolls the page but doesn't help a keyboard user at all isn't a broken link — it's the well-known gap between browsers scrolling to a fragment target and browsers moving keyboard focus to it, which only happens automatically when the target is itself focusable. Add tabindex="-1" to the skip link's target (or a dedicated hidden heading just before it), add an explicit .focus() call as a defensive fallback across browsers, and test the fix by continuing to tab after activation rather than just confirming the page scrolled.