swjh
All posts

A snippet to set the Sequential Focus Navigation Starting Point (SFNSP)

⬪Updated: ⬪7 min read
a11yhtmljssnippet

A small helper to tell the browser where Tab navigation should start from, without leaving visible focus on the element. Useful in special cases where .focus() or href="#id" isn’t an option.

Disclaimer: This post assumes you have advanced knowledge of frontend development. The topic of Sequential Focus Navigation is quite niche and you should really know what you are doing.


When you open a modal, navigate to a new route in a SPA, or reveal a section of content, keyboard users expect Tab to start from a sensible point inside that new context — not from wherever focus happened to be before.

Native hash links (<a href="#section">) do this automatically: clicking one moves the browser’s Sequential Focus Navigation Starting Point (SFNSP) to the target element. But there’s currently no JavaScript API to do the same thing programmatically. The WHATWG issue tracking this has been open since 2019 — a native element.setSequentialFocusStartingPoint() might be implemented in browsers soon, but isn’t widely available yet.

Until that lands, this snippet is the closest workaround.

This is not a substitute for moving the actual focus. The SFNSP only affects where Tab resumes — natively it does not move document.activeElement, and screen readers largely ignore it. Usually you need screen readers to announce a context change, so you must move focus explicitly. This snippet is for the narrow case where you want Tab to start from a new location without announcing a focus move.

How it works

The trick relies on the browser updating its internal SFNSP whenever an element receives focus. Three steps:

  1. Set tabindex="-1" on the target element (so it can be focused programmatically even if it isn’t natively focusable).
  2. Call .focus() then .blur(), the browser records this element as the new starting point, but focus isn’t left on it. Logging document.activeElement reveals it actually lands on ‘body’.
  3. Restore the original tabindex so the element’s tab behaviour is unchanged.

The next time the user presses Tab, the browser resumes sequential navigation from this element.

When you might use it

A timing note: call moveSFNSP() only after the target element is in the DOM and painted. Calling it during a transition or before a newly revealed element is rendered will silently fail in most browsers.

Next.js has an open bug where clicking a hash link does not set the SFNSP — the browser never receives the native anchor navigation event, so the next Tab press resumes from the wrong place. Vue’s Nuxt partly solved this in NuxtLink.

The workaround is to intercept the click and call moveSFNSP yourself.

// HashLink.tsx
import { type ComponentProps } from "react";
import Link from "next/link";
import { moveSFNSP } from "@/helpers/moveSFNSP";

type HashLinkProps = ComponentProps<typeof Link> & { href: string };

export const HashLink = ({ onClick, ...rest }: HashLinkProps) => {
  function handleClick(e: React.MouseEvent<HTMLAnchorElement>) {
    try {
      const id = decodeURIComponent(rest.href.replace(/^#/, ""));
      const target = document.getElementById(id);
      if (target) moveSFNSP(target);
    } catch {
      // ignore decodeURIComponent errors on malformed URIs
    }
    onClick?.(e);
  }

  return <Link {...rest} onClick={handleClick} />;
};

This restores the behaviour a native <a href="#main"> would give: the next Tab press enters the linked section.

SFNSP and screen readers

The SFNSP is a browser-internal concept. Screen readers do not observe it directly. Sarah Higley’s 2020 testing across NVDA, JAWS, Narrator, VoiceOver, and TalkBack found:

The practical upshot: relying on SFNSP alone is not sufficient for accessibility. For modals, route changes, or any meaningful context switch, always move visible focus explicitly. This snippet is most useful as a complement to focus management, not a replacement for it.

Demo

Click the button to call moveSFNSP() on the target box, then press Tab or Shift + Tab, the focus should move to the adjacent demo links.

Link A before the target
SFNSP target
Link B after the target

The code: moveSFNSP.ts

Use with caution

This is a hack. It works in current browsers but relies on undocumented internal behaviour. Stick to native href="#id" where you can. Test with a real keyboard and screen reader before shipping.

Copy & paste this to your/project/helpers/moveSFNSP.ts:

/**
 * Sets the Sequential Focus Navigation Starting Point (SFNSP) to the given
 * element without leaving visible focus on it.
 *
 * When Tab is next pressed, the browser will start sequential focus navigation
 * from this element rather than from wherever focus currently is.
 *
 * There is no native browser API for this yet — see https://github.com/whatwg/html/issues/5326
 * The trick: temporarily assign tabindex="-1", call focus() + blur() to move
 * the browser's internal starting point, then restore the original tabindex.
 *
 * Credit: Rich Harris — https://github.com/whatwg/html/issues/5326#issuecomment-1150090942
 *
 * @param element - The element to set as the focus starting point.
 */
export function moveSFNSP(element: HTMLElement): void {
  const tabindex = element.getAttribute("tabindex");

  element.setAttribute("tabindex", "-1");
  element.focus({ preventScroll: true });
  element.blur();

  if (tabindex !== null) {
    element.setAttribute("tabindex", tabindex);
  } else {
    element.removeAttribute("tabindex");
  }
}

Alternative approach

Philipp Daun’s variant prepends an invisible focusable element, focuses it, then removes it. Apparently the primary approach does not working reliably in all versions of Safari.

function moveSFNSP(element: HTMLElement): void {
  const focusEl = document.createElement("div");
  focusEl.setAttribute("tabindex", "-1");
  focusEl.style.cssText =
    "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(1px,1px,1px,1px);clip-path:inset(50%);outline:none";
  element.prepend(focusEl);
  focusEl.focus({ preventScroll: true });
  focusEl.remove();
}

The downside: Patrick Lauke tests concluded that with JAWS, removing the focused element causes focus to reset to the very start of the page — making the interaction worse than doing nothing. This applies to both variants, but the invisible-element approach may be more likely to trigger it. As always; test thoroughly before using in production.

Further reading