Blog

Part 47 · Keyboard Navigation in React

Make a React app work with only a keyboard. Learn Tab order, tabIndex, focus rings, moving focus with refs, a focus trap for a modal, skip links and arrow keys.

Many people use a web page with only a keyboard. They press Tab to move, and Enter or Space to press a button. Part 6 met focus and key events. Part 14 moved the focus with a ref. Part 39 moved it between tabs with the arrow keys.

Part 42 built a modal, but one job was still missing. Tab could leave the modal and reach the page behind it. This part builds the missing piece, a focus trap. Part 46 covered ARIA, which tells screen readers what things are. This part is about what keys do.

We’ll cover the Tab order, tabIndex, focus rings and where the focus should go after something changes. Then the trap, a skip link, arrow keys in a list, and keyboard shortcuts.

Try this first

Read this code. Don’t press Run yet.

import { useState, type FocusEvent } from 'react'

export default function App() {
  const [focused, setFocused] = useState('nothing')

  function handleFocus(e: FocusEvent<HTMLDivElement>) {
    setFocused(e.target.textContent ?? '')
  }

  return (
    <div onFocus={handleFocus}>
      <p>Click this line. Then press Tab three times.</p>
      <div style={{ display: 'flex', flexDirection: 'row-reverse', justifyContent: 'flex-end', gap: 8 }}>
        <button>One</button>
        <button>Two</button>
        <button>Three</button>
      </div>
      <p>Focus is on: {focused}</p>
    </div>
  )
}

The buttons are written One, Two, Three. But flexDirection: 'row-reverse' draws them the other way round. On the screen you see Three, Two, One, from left to right.

Make a guess. You’ll click the first line, then press Tab. Which button gets the focus first: Three, on the left, or One?

Now press Run and try it.

One gets the focus first, even though it is on the right. The next Tab goes to Two, and then to Three. The ring moves from right to left. We tried it in Chromium, Firefox and WebKit, and all three did the same. A fourth Tab left the result box.

All our browser tests in this part ran on Linux, with no window open. Our WebKit is the base of Safari, but it isn’t Safari on a Mac. On a Mac, Safari may skip buttons when you press Tab. The W3C keyboard guide says that on a Mac, the Tab order holds “only form elements” by default. Text boxes are form elements. Safari has a setting called “Press Tab to highlight each item on a webpage”. Apple says it lets you “Move around a webpage to highlight links and buttons without using the mouse”. Turn it on to try these examples, as Part 46 said.

The line at the bottom says where the focus is. onFocus sits on the outer <div>. In React, focus events bubble up, as Part 6 showed. So the outer <div> hears about the focus on any button inside it. e.target is the button that got the focus.

Who uses the keyboard

WebAIM is a group that teaches people how to make pages everyone can use. Its page on the keyboard lists who uses one:

  • people who can’t use their hands well, such as people whose hands shake;
  • people who can’t see the screen. WebAIM says blind users “typically use a keyboard”;
  • people with little or no use of their hands. Some use special keyboards, or other tools that act like one;
  • people who find the keyboard faster.

The W3C, the group that writes web standards, says the same: “Many people use only the keyboard”. Some type with a stick held in the mouth. People who control a computer with their voice use it too. The W3C says speech input “uses the keyboard interface in the background”.

So the keyboard isn’t a small extra. For some people, it’s the only way in. These are the keys they expect:

  • Tab moves to the next thing you can use. Shift+Tab moves back.
  • Enter and Space press a button. Enter follows a link.
  • The arrow keys move inside a group, like the tabs in Part 39.
  • Escape closes a box. The W3C calls it a common way out.

Tab follows the page order

The Tab key doesn’t look at where things are drawn. It follows the order of the tags in the page, the DOM from Part 14. The W3C’s guide for keyboards says so: “The default order of elements in the tab sequence is the order of elements in the DOM.”

That’s what “Try this first” showed. CSS drew the buttons in a different order, but Tab went in page order. MDN tells you to avoid CSS that changes the order of things you can focus. It makes the page “difficult” for people who use a keyboard. The W3C guide adds that a screen reader also reads in page order. So write your JSX in the order you want people to reach it. Then use CSS to place things, but don’t make the order look different.

Some tags are in the Tab order on their own. MDN lists them. The main ones are a link with href, <button>, <input>, <select>, <textarea> and <summary>. A <div>, a <p> or an <h2> is not.

tabIndex: 0, -1, and why not 1

Part 39 explained the first two values:

  • tabIndex={0} puts an element in the Tab order, at its place in the page.
  • tabIndex={-1} keeps it out of the Tab order. Code can still focus it, with focus().

A number above 0 is allowed too. It makes an element go first. Try it:

export default function App() {
  return (
    <div>
      <p>Click this line. Then press Tab.</p>
      <button>A</button>
      <button tabIndex={2}>B</button>
      <button tabIndex={1}>C</button>
      <button>D</button>
      <button tabIndex={-1}>E</button>
    </div>
  )
}

Click the first line, then press Tab a few times. Where did B and C go?

We tried it in all three browsers. From the first line, Tab went to A, then D, then out of the box. B and C were never reached. MDN describes the rule: every element with a number above 0 comes first, in number order. Then come all the others, in page order. A click on the line starts Tab from that place in the page. From there, Tab goes on through the elements with no number. B and C come before all of those, so Tab had already gone past them.

From the Edit button above the box, Chromium and WebKit went C, B, A, D, as the rule says. Firefox stopped on the result box itself first, then went A, D. It never reached B and C either. From D, Shift+Tab went back through A, B and C in all three. E never got the focus from Tab, but focus() in code worked.

So a positive number makes the order hard to guess, and different from browser to browser. MDN says: “You are recommended to only use 0 and -1 as tabindex values.” WebAIM says the same. If the order is wrong, move the tags in the JSX.

A <div> that acts like a button

Part 46 showed what a <div> needs to act like a button. It needs a role, tabIndex={0} and code for Enter and Space. Here it is next to a real button:

import { useState, type KeyboardEvent } from 'react'

export default function App() {
  const [likes, setLikes] = useState(0)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault()
      setLikes(likes + 1)
    }
  }

  return (
    <div>
      <div role="button" tabIndex={0} onClick={() => setLikes(likes + 1)} onKeyDown={handleKeyDown}>
        Like (a div)
      </div>
      <button onClick={() => setLikes(likes + 1)}>Like (a button)</button>
      <p>Likes: {likes}</p>
    </div>
  )
}

e.key is ' ', one space, for the Space key. We tried both in all three browsers. Tab reached the <div>, and Enter and Space each added one like. e.preventDefault() matters here. We tried a plain long page, not the result box. There, Space on a <div role="button"> with no handler scrolled the page down, in all three browsers. On a <button>, it didn’t. In the result box, only Firefox scrolled.

Even then, it isn’t quite a button. We had 2 likes, and held Space down on the real <button>. It still said “Likes: 2”. When the key came up, it said “Likes: 3”. So a <button> counts Space when the key comes up. Our <div> counts it at once. MDN’s page on tabindex warns against “using a <div> element to describe a button”. Use <button> to do something, and <a href> to go somewhere.

Show where the focus is

A mouse user can see the pointer. A keyboard user needs to see the focus. Browsers draw a focus ring, a line around the element that has the focus. CSS calls that line an outline.

CSS has two ways to style it:

  • :focus matches the element that has the focus, always.
  • :focus-visible matches it only when the browser thinks a ring would help. MDN says the browser uses its own rules for this. A mouse click on a button usually gets no ring. A text box, or a key press, does.

The result box can’t load a CSS file, so this example puts the CSS in a <style> tag. In your own app, put it in a CSS file, as in Part 10.

export default function App() {
  return (
    <div>
      <style>{`
        .with-focus:focus { outline: 3px solid orange; }
        .with-focus-visible:focus-visible { outline: 3px solid orange; }
        .no-ring:focus { outline: none; }
      `}</style>
      <button className="with-focus">:focus</button>
      <button className="with-focus-visible">:focus-visible</button>
      <button className="no-ring">No ring</button>
      <input aria-label="Your name" className="with-focus-visible" />
    </div>
  )
}

Click each one with the mouse, the text box too. Then press Shift+Tab and Tab to move between them.

We measured each one in Chromium, Firefox and WebKit. Our three gave the same answers:

Mouse click Tab
Button styled with :focus orange ring orange ring
Button styled with :focus-visible no ring orange ring
Button with outline: none no ring no ring
Text box styled with :focus-visible orange ring orange ring

Safari on a Mac may differ in the mouse column. MDN says a click focuses a button in most browsers, “but Safari does not, by design”. Then no ring shows at all.

The third row is the problem. With outline: none, a keyboard user can’t see where they are. WebAIM says to avoid outline:none and other styles that hide the focus. If you want your own look, write it with :focus-visible, so keyboard users still get a ring.

What about focus() called by your code? We tested that too. After a mouse click, a focus() call gave no ring. After a key press, it did. There was one exception. When the modal below opened from a mouse click, Firefox still showed the ring on Close. Chromium and WebKit didn’t. You can ask for the ring with focus({ focusVisible: true }). That worked in all three browsers.

Moving the focus with a ref

Most of the time, the browser moves the focus and you don’t need to. But when your code changes the page, the focus can get lost. The W3C guide gives two examples: you close a dialog, or you delete an item from a list. The element that had the focus is hidden or gone. Then, the guide says, “browsers move focus to the body element”. The body is the whole page. Nothing useful has the focus, and a screen reader can’t say where you are.

So when your code removes the focused element, move the focus yourself. Here is where it should go:

  • A dialog opens: into the dialog. Part 42 did this.
  • A dialog closes: back to the button that opened it. Part 42 did this too.
  • An item is deleted: to the next item, the guide says. If it was the last, we use the one before it.
  • A new page shows: to its heading. Part 37 did this.

And when the page first loads? The W3C guide says don’t move the focus then. It names two cases where it’s fine. The page has one main job that almost everyone does at once. Or people use the page very often. A search page, where almost everyone types first, could be one.

After a delete

Here is a shopping list. Press Tab until “Delete Eggs” has the focus, then press Enter.

import { useRef, useState } from 'react'

type Item = { id: number; name: string }

export default function App() {
  const [items, setItems] = useState<Item[]>([
    { id: 1, name: 'Milk' },
    { id: 2, name: 'Eggs' },
    { id: 3, name: 'Bread' },
  ])
  const buttonsRef = useRef(new Map<number, HTMLButtonElement>())
  const headingRef = useRef<HTMLHeadingElement>(null)

  function remove(index: number) {
    const next = items[index + 1] ?? items[index - 1]
    if (next) {
      buttonsRef.current.get(next.id)?.focus()
    } else {
      headingRef.current?.focus()
    }
    setItems(items.filter((_, i) => i !== index))
  }

  return (
    <div>
      <h2 ref={headingRef} tabIndex={-1}>Shopping list ({items.length})</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            {item.name}{' '}
            <button
              ref={(node) => {
                if (node !== null) buttonsRef.current.set(item.id, node)
                return () => {
                  buttonsRef.current.delete(item.id)
                }
              }}
              onClick={() => remove(index)}
            >
              Delete {item.name}
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

The focus goes to “Delete Bread”, the next item. Press Enter again, and it goes to “Delete Milk”, the one before, because Bread was last. Delete Milk too. Now the list is empty, so the focus goes to the heading. We saw exactly this in all three browsers.

remove works out where the focus should go before it changes the state. The next item’s button is still on the page then. Its node waits in the Map from Part 14. After the update, React keeps that button, because its key didn’t change. So the focus stays on it.

The heading has tabIndex={-1}. A heading can’t take the focus on its own. With -1, code can focus it, but Tab doesn’t stop there.

What if the element you want to focus isn’t on the page yet, like a new item? Then React must put it on the page first. React’s docs show flushSync from react-dom for a case like this: scrolling to a new item. It makes React update the page at once, inside your handler. React’s docs also say it “can significantly hurt performance”. Use it rarely.

An everyday example

You’re reading a book with your finger on a line. Someone tears out that page. Your finger now points at nothing. A good helper puts your finger on the next page.

The exact version

We deleted “Eggs” with no focus code at all. In all three browsers, the focus went to the body. Then we pressed Tab. Chromium and Firefox went on to “Delete Bread”, as if they remembered the place. WebKit started again from the top, at “Delete Milk”. So the Tab key may hide the problem in some browsers. A screen reader user still hears nothing about where they are. Don’t count on the browser. Move the focus.

A focus trap for the modal

Part 42’s modal moved the focus in and back out. But Shift+Tab from Close went to “Open settings”, behind the dark layer. The W3C’s dialog page says Tab and Shift+Tab “do not move focus outside the dialog”. At the last element, Tab goes to the first. At the first, Shift+Tab goes to the last. That loop is a focus trap.

Here is the Part 42 modal again, with a trap. The new parts are useFocusTrap, tabIndex={-1} and inert. There’s also a Name box and a Save button, and a line that shows where the focus is.

import { useEffect, useEffectEvent, useId, useRef, useState, type FocusEvent, type ReactNode, type RefObject } from 'react'
import { createPortal } from 'react-dom'

const FOCUSABLE = ['a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]']
  .map((s) => `${s}:not([tabindex="-1"])`)
  .join(', ')

function useFocusTrap(boxRef: RefObject<HTMLElement | null>) {
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      const box = boxRef.current
      if (e.key !== 'Tab' || box === null) return
      const stops = Array.from(box.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
        (el) => !el.closest('[inert]') && el.checkVisibility?.() !== false
      )
      if (stops.length === 0) {
        e.preventDefault()
        return
      }
      const now = stops.indexOf(document.activeElement as HTMLElement)
      if (e.shiftKey && now <= 0) {
        e.preventDefault()
        stops[stops.length - 1].focus()
      } else if (!e.shiftKey && (now === -1 || now === stops.length - 1)) {
        e.preventDefault()
        stops[0].focus()
      }
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [boxRef])
}

function useKeyPress(key: string, onPress: () => void) {
  const onKey = useEffectEvent(onPress)
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === key) onKey()
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [key])
}

type ModalProps = {
  title: string
  onClose: () => void
  returnFocusTo?: RefObject<HTMLElement | null>
  children: ReactNode
}

function Modal({ title, onClose, returnFocusTo, children }: ModalProps) {
  const titleId = useId()
  const boxRef = useRef<HTMLDivElement>(null)
  const closeRef = useRef<HTMLButtonElement>(null)
  const pressedBackdrop = useRef(false)
  useKeyPress('Escape', onClose)
  useFocusTrap(boxRef)

  useEffect(() => {
    const opener = returnFocusTo?.current ?? (document.activeElement as HTMLElement | null)
    closeRef.current?.focus()
    return () => opener?.focus()
  }, [returnFocusTo])

  useEffect(() => {
    const before = document.body.style.overflow
    document.body.style.overflow = 'hidden'
    return () => {
      document.body.style.overflow = before
    }
  }, [])

  return createPortal(
    <div
      style={{ position: 'fixed', inset: 0, background: 'rgba(0, 0, 0, 0.5)', display: 'grid', placeItems: 'center' }}
      onPointerDown={(e) => {
        pressedBackdrop.current = e.target === e.currentTarget
      }}
      onClick={(e) => {
        if (pressedBackdrop.current && e.target === e.currentTarget) onClose()
      }}
    >
      <div
        ref={boxRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        tabIndex={-1}
        style={{ background: 'white', color: 'black', padding: 16, borderRadius: 8 }}
      >
        <h2 id={titleId} style={{ marginTop: 0 }}>{title}</h2>
        {children}
        <button ref={closeRef} onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  )
}

export default function App() {
  const [open, setOpen] = useState(false)
  const [where, setWhere] = useState('nothing')
  const openRef = useRef<HTMLButtonElement>(null)

  function handleFocus(e: FocusEvent<HTMLDivElement>) {
    const el = e.target
    setWhere(el.getAttribute('role') ?? el.getAttribute('aria-label') ?? el.textContent ?? '')
  }

  return (
    <div onFocus={handleFocus} style={{ minHeight: 280 }}>
      <main inert={open}>
        <h1>My page</h1>
        <button ref={openRef} onClick={() => setOpen(true)}>Open settings</button>
        <p>Focus is on: {where}</p>
      </main>
      {open && (
        <Modal title="Settings" onClose={() => setOpen(false)} returnFocusTo={openRef}>
          <p>
            <input aria-label="Name" placeholder="Name" />
          </p>
          <button>Save</button>{' '}
        </Modal>
      )}
    </div>
  )
}

The modal sits in a portal, but its focus events still reach the onFocus on App‘s <div>. React events follow the React tree, as Part 42 showed.

Run it and press “Open settings”. The focus goes to Close. Now press Tab a few times, then Shift+Tab a few times.

1. The modal opens. Focus moves to Close, the last stop. 2. Tab on the last stop. The trap sends focus to Name. 3. Tab on Name. The browser moves focus to Save. 4. Shift+Tab on Save. The browser moves focus to Name. 5. Shift+Tab on the first stop. The trap sends focus to Close. the page behind: <main inert> My page Open settings never gets the focus role="dialog", in a portal Settings Name first stop Save Close last stop

Tab and Shift+Tab inside a trapped modal. Press play, or step through it.

The same steps in words:

  1. The modal opens. Part 42’s Effect moves the focus to Close, the last stop.
  2. You press Tab on the last stop. The trap stops the browser and focuses Name, the first stop.
  3. You press Tab on Name. The trap does nothing, and the browser moves the focus to Save.
  4. You press Shift+Tab on Save. The browser moves the focus back to Name.
  5. You press Shift+Tab on Name, the first stop. The trap focuses Close, the last.

We pressed these keys in Chromium, Firefox and WebKit. Four Tabs from Close went Name, Save, Close, Name. Four Shift+Tabs from Name went Close, Save, Name, Close. “Open settings” never got the focus.

How useFocusTrap works

The hook adds one keydown listener to the document, in an Effect. It cleans it up when the modal closes. It ignores every key but Tab. On Tab, it does this:

  1. It finds the stops. A stop is an element that Tab can reach. FOCUSABLE is a CSS selector, a pattern that picks tags. It picks links with href, buttons, boxes and anything with a tabindex. Elements with disabled are left out, because Tab skips them. Each part also has :not([tabindex="-1"]), because Tab skips those too. A row of tabs from Part 39 has -1 on all but one tab.
  2. filter drops what Tab can’t reach either: anything inside an inert part, and anything not drawn. MDN says checkVisibility() gives false for an element with no box, like one with display: none. Older browsers don’t have it, so ?.() skips the check there.
  3. It finds where the focus is now, with stops.indexOf(document.activeElement). -1 means the focus isn’t on any stop.
  4. Shift+Tab on the first stop, or from nowhere, goes to the last stop.
  5. Tab on the last stop, or from nowhere, goes to the first stop.
  6. In those two cases, it calls e.preventDefault() first. That stops the browser’s own Tab move. In every other case, the browser moves the focus as usual.

Notice that it finds the stops each time you press Tab. The modal can change while it’s open. A box can appear, or a button can go away. Reading the stops on every press always gets the real list.

If there are no stops at all, it calls e.preventDefault() and does nothing else. The focus stays where it is, inside the box.

Strict Mode

The trap’s Effect has [boxRef] as its list of dependencies. A ref object is the same object on every render. So the Effect runs once when the modal opens, and cleans up once when it closes.

In development, Strict Mode runs setup, cleanup and setup again. That adds a listener, removes it and adds it again. So there’s still only one. We counted the document’s keydown listeners in our test. With the modal open there were 2: the trap and Escape. After Escape there were 0. That was the same with Strict Mode on and off.

A click inside the white box

Part 42 found one more gap. A click on the text inside the white box sent the focus to the body. Then the next Tab started from outside.

tabIndex={-1} on the dialog box fixes that. A click inside the box now focuses the box itself. We clicked the title “Settings” in all three browsers. The focus went to the dialog box. Then Tab went to Name, and Shift+Tab went to Close. The box isn’t a stop, so the trap sees -1 and brings the focus to the first or last stop.

inert: the page behind can’t be reached

<main inert={open}> makes the page behind inert, as in Part 42. MDN says an inert element and everything inside it “cannot receive focus or be clicked”. It is also hidden from screen readers.

The modal’s tags aren’t inside <main>. The portal put them at the end of <body>. So the modal still works. In React 19.3, inert={true} became inert="", and inert={false} left the attribute out.

Do you need both the trap and inert? We tried each one alone, in all three browsers.

  • inert, no trap. Tab from Close left the result box. Shift+Tab went back to Save and Name, and then left too. So inert alone doesn’t keep Tab inside.
  • The trap, no inert. Tab stayed inside. But focus() in code on “Open settings” worked, while the modal was open. So the page behind can still get the focus, and a screen reader may still read it.

They do different jobs, so use both.

What about <dialog>?

Part 42 showed that showModal() makes the page inert for you. But does Tab stay inside? We tried a plain page, not in a result box, with a <dialog> holding buttons A and B. In Chromium, Tab went A, B, and then the page lost the focus. The next Tab came back to A. WebKit did the same, but came back to the dialog itself. Our headless Firefox kept the focus on B. With inert alone on a plain page, Chromium and WebKit went from B out of the page, then to A. Firefox again stayed on B.

When the page loses the focus, the focus has gone out to the browser itself. The W3C’s page on keyboard traps says that’s allowed. In a trapped dialog, it says, “the focus cycle might still include user agent controls”. A user agent is the browser. So with <dialog>, Tab can reach the browser, but never the page behind. With our trap, Tab stays in the dialog.

An everyday example

A trapped modal is like a small room. While you’re in it, the other rooms are locked. When you walk past the last thing in the room, you come back to the first thing. To leave, you use Close or Escape.

The exact version

Why the filter and the -1 checks? We tried the trap without the filter, with a button that has display: none after Close. In all three browsers:

  • Tab on Close left the result box. To the trap, Close wasn’t the last stop any more, so it let the browser move on. The browser skipped the hidden button and left the modal.
  • Shift+Tab on Name stayed on Name. The trap tried to focus the hidden button, and that did nothing.

With the filter, Tab on Close went to Name, and Shift+Tab on Name went to Close. A button with tabIndex={-1} after Close gave the same trouble without the -1 checks. In our test, Tab on Close wasn’t stopped. With them, it went to Name.

Our list is still shorter than the rules a browser uses for Tab. Test your own modal with the keyboard.

Giving the focus back

When a dialog closes, Part 42’s cleanup calls opener?.focus(). The W3C dialog page says the focus goes back to the button that opened it. But not if that button “no longer exists”.

When can it be gone? Say each item in a list has an Edit button that opens a dialog. The dialog has a Delete button. After Delete, the item and its Edit button are gone. Here is that case:

import { useEffect, useRef, useState, type FocusEvent, type RefObject } from 'react'

type DialogProps = {
  returnFocusTo: RefObject<HTMLElement | null>
  fallbackTo: RefObject<HTMLElement | null>
  onDelete: () => void
  onClose: () => void
}

function EditDialog({ returnFocusTo, fallbackTo, onDelete, onClose }: DialogProps) {
  const closeRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    const opener = returnFocusTo.current
    closeRef.current?.focus()
    return () => {
      if (opener?.isConnected) opener.focus()
      else fallbackTo.current?.focus()
    }
  }, [returnFocusTo, fallbackTo])

  return (
    <div role="dialog" aria-label="Edit item">
      <button onClick={onDelete}>Delete it</button> <button ref={closeRef} onClick={onClose}>Close</button>
    </div>
  )
}

export default function App() {
  const [items, setItems] = useState(['Milk', 'Eggs', 'Bread'])
  const [editing, setEditing] = useState<string | null>(null)
  const [where, setWhere] = useState('nothing')
  const openerRef = useRef<HTMLElement | null>(null)
  const headingRef = useRef<HTMLHeadingElement>(null)

  function handleFocus(e: FocusEvent<HTMLDivElement>) {
    setWhere(e.target.textContent ?? '')
  }

  return (
    <div onFocus={handleFocus}>
      <main inert={editing !== null}>
        <h2 ref={headingRef} tabIndex={-1}>Shopping list</h2>
        {items.map((item) => (
          <button
            key={item}
            onClick={(e) => {
              openerRef.current = e.currentTarget
              setEditing(item)
            }}
          >
            Edit {item}
          </button>
        ))}
        <p>Focus is on: {where}</p>
      </main>
      {editing !== null && (
        <EditDialog
          returnFocusTo={openerRef}
          fallbackTo={headingRef}
          onClose={() => setEditing(null)}
          onDelete={() => {
            setItems(items.filter((item) => item !== editing))
            setEditing(null)
          }}
        />
      )}
    </div>
  )
}

Press “Edit Milk”, then Close. The focus goes back to “Edit Milk”. Now press “Edit Eggs”, then “Delete it”. “Edit Eggs” is gone, so the focus goes to the heading instead. Without the isConnected check, focus() on the removed button did nothing, and the focus fell to the body.

isConnected is true when an element is still on the page. fallbackTo is a second ref, for when it isn’t. Here it’s the list’s heading, with tabIndex={-1}.

The opener comes from a ref, as in Part 42, not from document.activeElement. That matters here, because of inert and Strict Mode together. Strict Mode runs setup, cleanup and setup. The first cleanup tries to focus the opener. But the opener is inside <main inert>, and an inert element can’t take the focus. So the focus stays on Close. If the second setup read document.activeElement, it would save Close as the opener. We tried that version in Chromium, Firefox and WebKit. The saved openers were “Open settings” and then “Close”, so the focus never went back. With the ref, the second setup reads the same button again.

Many pages start with the same row of links on every page. A keyboard user has to Tab through all of them to reach the content. A skip link fixes that. It’s the first link on the page. It says “Skip to content” and jumps over the rest.

import { useRef, type MouseEvent } from 'react'

export default function App() {
  const mainRef = useRef<HTMLElement>(null)

  function skip(e: MouseEvent<HTMLAnchorElement>) {
    e.preventDefault()
    mainRef.current?.focus()
  }

  function stayHere(e: MouseEvent<HTMLDivElement>) {
    e.preventDefault()
  }

  return (
    <div onClick={stayHere}>
      <style>{`
        .skip { position: absolute; left: -10000px; }
        .skip:focus { left: 8px; top: 8px; background: white; color: black; padding: 8px; }
      `}</style>
      <a className="skip" href="#main" onClick={skip}>Skip to content</a>
      <nav>
        <a href="#home">Home</a> <a href="#news">News</a> <a href="#sport">Sport</a>{' '}
        <a href="#weather">Weather</a> <a href="#help">Help</a>
      </nav>
      <main id="main" ref={mainRef} tabIndex={-1}>
        <h1>Today</h1>
        <p>The main story starts here.</p>
        <a href="#story">Read the story</a>
      </main>
    </div>
  )
}

Press Tab until “Skip to content” appears at the top of the result box. Press Enter. The focus jumps to <main>. Press Tab once more, and it goes to “Read the story”, past all five links.

We checked each step in all three browsers. Before the focus, the link sat 10,000 pixels to the left, off the screen. With the focus, it moved to 8 pixels from the left. After Enter, the focus was on <main>, and the link went back off the screen.

Why hide it like that? WebAIM says to hide the link off the screen, and show it when it gets the focus. display: none or hidden would take it out of the Tab order, so nobody could use it.

Why the click handler

On a plain page, href="#main" is enough. We tried a plain page, not in a result box. Enter on the link moved the focus to <main>, when it had tabindex="-1". Without it, the focus went to the body, but the next Tab still went to “Read the story”. The browser remembers where the jump landed.

In the result box, a plain href="#main" breaks. A link’s address is worked out from the page that holds the box, as Part 37 found. So #main points at this lesson page, with #main added. We pressed Enter on a plain skip link in all three browsers. The box loaded this whole lesson page, and the app was gone. A hash router, like Part 37’s, also reads the part after # as the page to show.

So our link calls e.preventDefault() to stop the jump, and moves the focus with a ref. With the handler, the address stayed the same. The other links in this example would load the lesson page too. So the outer <div>‘s onClick, stayHere, stops them all. A click on a link bubbles up to it, and e.preventDefault() there still stops the browser. In your own app, those would be real links to other pages.

Arrow keys in a group

Tab moves from one thing to the next. Inside a group, like a row of tabs or a list of choices, the arrow keys move instead. Part 39 built this for tabs, with a roving tabindex. Only one tab has tabIndex={0}, so the whole row is one Tab stop. Part 39 explains it.

Arrow keys between accordion buttons

Part 38 said some accordions let the arrow keys move between their buttons. The W3C accordion page lists only Enter, Space, Tab and Shift+Tab. It says all the buttons stay “in the page Tab sequence”. Radix UI adds more. In Radix, the Down arrow moves to the next button and the Up arrow to the one before. Home moves to the first and End to the last.

So this is an extra path, not a roving tabindex. Every button keeps its Tab stop.

import { useState, type KeyboardEvent } from 'react'

const sections = [
  { title: 'Shipping', text: 'We ship in 2 days.' },
  { title: 'Returns', text: 'Send it back within 30 days.' },
  { title: 'Payment', text: 'We take cards and cash.' },
]

function nextIndex(key: string, now: number, count: number) {
  if (key === 'ArrowDown') return (now + 1) % count
  if (key === 'ArrowUp') return (now - 1 + count) % count
  if (key === 'Home') return 0
  if (key === 'End') return count - 1
  return null
}

export default function App() {
  const [open, setOpen] = useState<string | null>(null)

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const triggers = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('h3 > button'))
    const now = triggers.findIndex((t) => t === e.target)
    if (now === -1) return
    const next = nextIndex(e.key, now, triggers.length)
    if (next === null) return
    e.preventDefault()
    triggers[next].focus()
  }

  return (
    <div onKeyDown={handleKeyDown}>
      {sections.map((s) => (
        <div key={s.title}>
          <h3>
            <button aria-expanded={open === s.title} onClick={() => setOpen(open === s.title ? null : s.title)}>
              {s.title}
            </button>
          </h3>
          <p hidden={open !== s.title}>{s.text}</p>
        </div>
      ))}
    </div>
  )
}

We left out the ids and aria-controls from Part 38, to keep it short. A real accordion needs them.

Click “Shipping”, then use the arrow keys, Home and End. In all three browsers, the Down arrow went Returns, Payment, and then back to Shipping. The Up arrow from Shipping went to Payment. End and Home went to the last and the first. Tab from Shipping went to Returns, as before.

nextIndex is Part 39’s function, with the Down and Up arrows in place of Right and Left. The handler sits on the outer <div>, so it hears keys from inside the panels too. findIndex gives -1 when the key didn’t come from one of the buttons. Then the handler returns, so the arrow keys still work normally inside a panel.

aria-activedescendant: the focus stays on the list

There’s a second way to move inside a group. The focus stays on the group itself. An attribute, aria-activedescendant, says which item is the active one. MDN says the browser then tells screen readers about that item as if it had the focus. You draw the highlight yourself. The W3C guide describes both ways. Here is a listbox, a list where you pick one item:

import { useId, useState, type KeyboardEvent } from 'react'

const colors = ['Black', 'Blue', 'Brown', 'Green', 'Orange', 'Red', 'White']

export default function App() {
  const baseId = useId()
  const [active, setActive] = useState(0)

  function handleKeyDown(e: KeyboardEvent<HTMLUListElement>) {
    let next: number
    if (e.key === 'ArrowDown') next = Math.min(active + 1, colors.length - 1)
    else if (e.key === 'ArrowUp') next = Math.max(active - 1, 0)
    else if (e.key === 'Home') next = 0
    else if (e.key === 'End') next = colors.length - 1
    else if (e.key === ' ') next = active
    else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
      const letter = e.key.toLowerCase()
      const order = [...colors.slice(active + 1), ...colors.slice(0, active + 1)]
      const match = order.find((c) => c.toLowerCase().startsWith(letter))
      if (match === undefined) return
      next = colors.indexOf(match)
    } else return
    e.preventDefault()
    setActive(next)
  }

  return (
    <div>
      <p id={`${baseId}-label`}>Pick a color:</p>
      <ul
        role="listbox"
        tabIndex={0}
        aria-labelledby={`${baseId}-label`}
        aria-activedescendant={`${baseId}-${active}`}
        onKeyDown={handleKeyDown}
        style={{ listStyle: 'none', padding: 4, width: 160, border: '1px solid gray' }}
      >
        {colors.map((color, i) => (
          <li
            key={color}
            id={`${baseId}-${i}`}
            role="option"
            aria-selected={i === active}
            onClick={() => setActive(i)}
            style={{ padding: 4, background: i === active ? 'lightblue' : undefined }}
          >
            {color}
          </li>
        ))}
      </ul>
      <p>Picked: {colors[active]}</p>
    </div>
  )
}

Click “Pick a color:” and press Tab. Then use the arrow keys, Home and End, or type a letter.

We tried it in all three browsers. Tab focused the <ul>. Two Down arrows made Brown active. End went to White, and Home back to Black. The focus stayed on the <ul> the whole time. Only aria-activedescendant and the blue background moved.

handleKeyDown works out next, the new active item. Space keeps the same item, and so reaches e.preventDefault(). Without that line, Space matched no color and returned early. In our test, Firefox then scrolled the lesson page. A key it doesn’t know reaches else return, so it never gets to e.preventDefault(). The Down arrow stops at the last item. The W3C listbox page says it moves to the next item, and doesn’t ask it to wrap.

The ids come from useId, as in Part 38. aria-activedescendant must name the id of the active item.

Which way should you pick? A roving tabindex moves the real focus. The W3C guide names one benefit: the browser then scrolls the item into view. With aria-activedescendant, the guide says you must do that yourself. MDN says aria-activedescendant works only on a few roles, like a listbox. A dialog can’t use it. It is useful when the focus must stay in one place. One case is a text box with a list of suggestions under it. Part 52 builds one.

Typeahead

Typeahead means you type a letter, and the list jumps to an item that starts with it. The W3C listbox page recommends it “for all listboxes”. It’s most useful for lists longer than seven items.

Our version handles one letter at a time. e.key.length === 1 is true for one letter, number or sign. It’s false for names like "ArrowDown". The checks for ctrlKey, metaKey and altKey leave keys like Ctrl+A alone. order is the list starting after the active item, then going round to the top. find gives the first item in it that starts with the letter. In our test, “b” went to Blue, “b” again to Brown, and a third “b” back to Black. “g” went to Green. “x” matched nothing, so nothing moved. The W3C page also describes typing several letters fast, like “br” for Brown. That needs a timer, so we left it out.

The W3C page also marks Home and End as “strongly recommended” for lists with more than five items.

Handling keys well

What e.key gives you

We pressed some keys in all three browsers and logged e.key for each. The test tool typed as if on a US keyboard. All three agreed:

You press e.key e.code
a "a" "KeyA"
Shift+A "Shift", then "A" "ShiftLeft", then "KeyA"
Space " " "Space"
Enter "Enter" "Enter"
Escape "Escape" "Escape"
Down arrow "ArrowDown" "ArrowDown"

e.key is what the key means. e.code is which key it is on the keyboard. On another layout, the same e.code can give a different e.key. MDN’s list of key values has the names for the other keys. Notice that Shift fires its own keydown. And Space is a space, not the word "Space".

preventDefault for your keys only

Many keys have a browser action. The arrow keys and Space scroll the page. Tab moves the focus. When your code uses a key, call e.preventDefault(), so the browser doesn’t do its action as well.

We removed it from the accordion and pressed the Down arrow. The focus still moved to Returns. But the whole lesson page also scrolled down, by 40 pixels in Chromium, 69 in Firefox and 93 in WebKit. With e.preventDefault(), it didn’t move at all.

But only stop the keys you use. Our handlers return early for every other key. In our tests, a letter and Tab were never stopped. See the mistake below for what happens when they are.

Typing in other languages

Some languages, like Japanese and Chinese, are typed with a tool called an IME. You type a few keys, the IME shows choices, and Enter picks one. That Enter is for the IME, not for your app.

MDN’s page on keydown shows how to skip these presses: check isComposing, and also keyCode === 229. MDN says isComposing can be false for the first or last key, while keyCode is still 229. We tried it in Chromium, through its tools for programs that drive the browser. While a word was still being built, Enter’s keydown had isComposing: true. After that, a normal Enter had false. We sent the 229 ourselves in that test, so it proves nothing about keyCode.

React’s own event doesn’t have isComposing. In our test it was undefined. Read it from e.nativeEvent, the browser’s own event:

import type { KeyboardEvent } from 'react'

function handleKeyDown(e: KeyboardEvent<HTMLInputElement>, addTodo: () => void) {
  if (e.nativeEvent.isComposing || e.keyCode === 229) return
  if (e.key === 'Enter') addTodo()
}

keyCode is old. TypeScript marks it as deprecated, which means “don’t use it in new code”. MDN still uses it here, for this one case.

Keyboard shortcuts

A keyboard shortcut is a key that does something from anywhere on the page. Here, the / key jumps to the search box.

import { useEffect, useRef, useState } from 'react'

const NOT_TYPING = ['checkbox', 'radio', 'button', 'submit', 'reset']

function isTyping(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false
  if (target.isContentEditable) return true
  if (target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return true
  return target instanceof HTMLInputElement && !NOT_TYPING.includes(target.type)
}

export default function App() {
  const [on, setOn] = useState(true)
  const searchRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    if (!on) return
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return
      if (isTyping(e.target)) return
      e.preventDefault()
      searchRef.current?.focus()
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [on])

  return (
    <div>
      <label>
        <input type="checkbox" checked={on} onChange={(e) => setOn(e.target.checked)} /> The / key jumps to search
      </label>
      <p>
        <input ref={searchRef} aria-label="Search" placeholder="Search" />
      </p>
      <button>Something else</button>
    </div>
  )
}

Click “Something else”, then press /. The focus jumps to the search box. Type a/b there. The / goes into the box as normal text.

This one listener needs care in three places:

  • Not while typing. isTyping checks where the key came from. In a text box, / is just a letter. isContentEditable is true for text you can edit in place. A checkbox is an <input> too, but you can’t type in it. NOT_TYPING lists the kinds of <input> that take no text. With the focus on the checkbox, / still jumps to the search box.
  • Not with Ctrl, Alt or Meta. Those keys mean something else to the browser or the computer. Meta is the Windows key, or Command on a Mac. In our test, Ctrl+/ didn’t move the focus.
  • e.preventDefault(). Without it, the / you pressed landed in the search box. We saw "/" in the box in all three browsers.

In Firefox, / is already a browser key. It opens Quick Find, a search of the page. Firefox’s own code doesn’t start Quick Find when the page has called preventDefault(). So our shortcut takes / away from Firefox users. In our headless Firefox, / on a plain page took the focus off a button. With preventDefault(), the focus stayed.

That is one more reason for the checkbox, which turns the shortcut off. It isn’t just polite. The W3C has a rule about shortcuts made of one letter or symbol. A user must be able to turn them off. Or the user can change them to use a key like Ctrl or Alt as well. Or they must work only when their part has the focus. People who speak to their computer are the reason. The W3C gives an example: a person says “Hey Kim”, and the letters fire three shortcuts at once.

The Effect adds the listener only when on is true. With Strict Mode on, there was 1 listener after the first load, and 0 after we turned the shortcut off.

Don’t take the browser’s keys

The W3C guide lists keys to leave alone. Its list is longer than this one. Don’t use Ctrl, Alt, Shift or the Windows key with Tab, Enter, Space or Escape. The computer uses those. Don’t take the browser’s keys for the address bar, loading the page again, saved pages, history or Find. And don’t use keys that screen readers use, like Caps Lock or Insert with other keys.

A shortcut is only a faster path. The guide says everything a shortcut does must also work without it.

Common mistakes

A positive tabIndex

export default function App() {
  return (
    <div>
      <input aria-label="Name" />
      <input aria-label="Email" />
      <button tabIndex={1}>Send</button>
    </div>
  )
}

Someone wanted Tab to reach Send first. Now every other part of the page comes after it, and Send is skipped once you’re past it. You saw this with B and C above. Use 0 or -1, and put the tags in the right order.

outline: none with nothing in its place

The kata this part follows puts outline: 'none' on its list items. In our measurements, outline: none removed the ring for the mouse and for Tab, in all three browsers. If you don’t like the ring, style it with :focus-visible. Don’t remove it.

Moving the focus when the page loads

The kata’s list moves the focus in an Effect that runs when activeIndex changes:

import { useEffect, useRef, useState } from 'react'

const items = ['Home', 'Profile', 'Settings']

export default function App() {
  const [activeIndex] = useState(0)
  const listRef = useRef<HTMLUListElement>(null)

  useEffect(() => {
    const options = listRef.current?.querySelectorAll<HTMLElement>('[role="option"]')
    options?.[activeIndex]?.focus()
  }, [activeIndex])

  return (
    <div>
      <input aria-label="Search the site" />
      <ul ref={listRef} role="listbox" aria-label="Menu">
        {items.map((item, i) => (
          <li key={item} role="option" aria-selected={i === activeIndex} tabIndex={i === activeIndex ? 0 : -1}>
            {item}
          </li>
        ))}
      </ul>
    </div>
  )
}

An Effect also runs after the first render. So the list grabs the focus as soon as the page shows. We checked: after the first render, “Home” had the focus. The W3C guide says not to do that on most pages. Move the focus in the key handler instead, as Part 39 and the accordion above do.

A trap that saves its stops when it opens

The kata’s trap reads the stops once, when the modal opens. We tried that in our modal, with a “More” button that turns into a “Delete account” button. The saved list still held the old “More” button, now gone from the page. Shift+Tab on Name tried to focus it. Nothing happened, and the focus stayed on Name. Our trap reads the stops on every Tab. With it, the same Shift+Tab went to “Delete account”.

preventDefault on every key

import type { KeyboardEvent } from 'react'

function handleKeyDown(e: KeyboardEvent<HTMLDivElement>, moveDown: () => void) {
  e.preventDefault()
  if (e.key === 'ArrowDown') moveDown()
}

We put e.preventDefault() at the top of the accordion’s handler. In all three browsers, Tab on Shipping stayed on Shipping. Enter didn’t open the section either. Call it only after you know the key is one of yours.

Practice

Press Edit on the examples above.

  1. In the modal, add an Email box between Name and Save. Open the modal and press Tab on Close. Then press Shift+Tab on Name. Where does the focus go each time? Did you have to change useFocusTrap?
  2. In useFocusTrap, add console.log('trap on') before addEventListener. Change the cleanup so it also logs 'trap off'. Open the modal, press Tab twice, then press Escape. What does the Console show?
  3. Make the listbox wrap around. The Down arrow on White should go to Black, and the Up arrow on Black to White.
  4. In the shopping list, delete Bread first. Where does the focus go?
Answers
  1. Tab on Close goes to Name. Shift+Tab on Name goes to Close. You don’t change the hook. It finds the stops on every Tab press, so the new box is just one more stop. In our test, Tab on Email was left to the browser.
  2. When the modal opens: trap on, trap off, trap on. Strict Mode runs setup, cleanup and setup. The two Tabs add nothing, even though App renders again to update the focus line. The dependency, boxRef, is the same object. After Escape: one more trap off. With Strict Mode off, it would be just trap on, then trap off.
  3. Use %, as in Part 39: next = (active + 1) % colors.length for the Down arrow, and next = (active - 1 + colors.length) % colors.length for the Up arrow. In our test, End then the Down arrow gave Black.
  4. To “Delete Eggs”. Bread has no next item, so remove picks the one before it.

Interview questions

Try to answer each one out loud before you open the answer.

What do tabIndex={0}, tabIndex={-1} and a positive tabIndex do?

0 puts an element in the Tab order, at its place in the page. -1 keeps it out of the Tab order, but code can still focus it with focus(). A positive number should put the element before everything else, in number order. Avoid positive numbers. They make the order hard to guess. Once you’re past them, Tab doesn’t come back to them. In our test, Firefox coming in from outside the box skipped them too. MDN recommends only 0 and -1.

A strong answer says the real fix for a wrong order is to move the tags. It also says CSS order, like row-reverse or the order property, doesn’t change the Tab order.

What is the difference between :focus and :focus-visible?

:focus matches the focused element every time. :focus-visible matches only when the browser decides a ring would help. That’s usually after a key press, or in a text box. A mouse click on a button usually doesn’t count. So :focus-visible gives keyboard users a ring without showing one on every click.

A strong answer adds: never remove the ring without putting another in its place. And focus({ focusVisible: true }) asks the browser to show the ring when code moves the focus.

How would you build a focus trap for a modal?

Listen for keydown while the modal is open. On Tab, find the stops inside the modal. If the focus is on the last stop, call preventDefault() and focus the first. On Shift+Tab from the first stop, focus the last. If the focus isn’t on any stop, send it to the first or the last. Find the stops on every press, because the modal can change. Clean up the listener when it closes. Also move the focus in when it opens, and back to the opener when it closes.

A strong answer adds inert on the rest of the page. Then a click, code or a screen reader can’t reach it. It names the edge cases: hidden elements, radio groups, and a modal with nothing to focus.

Where should the focus go when a dialog closes, or when an item is deleted?

After a dialog: back to the element that opened it. If that element is gone, to another place that makes sense. After a delete: to the next item, or the one before it if it was the last. If the list is empty, go to its heading. If you do nothing, the focus falls to the body.

A strong answer mentions isConnected to check that the opener still exists. It also mentions tabIndex={-1} on a heading, so code can focus it.

Roving tabindex or aria-activedescendant: what’s the difference?

Both make a group one Tab stop and move inside it with the arrow keys. A roving tabindex moves the real focus. Only the current item has tabIndex={0}, and the others have -1. With aria-activedescendant, the focus stays on the group. The attribute names the id of the active item, and screen readers treat that item as focused.

A strong answer says a roving tabindex gets scrolling into view for free. aria-activedescendant fits when the focus must stay put, like a text box with suggestions. It works only on some roles, and you draw the highlight yourself.

Why stop the browser’s own action for arrow keys, but not for every key?

The arrow keys and Space scroll the page. If your list uses the Down arrow, the page would scroll as well. preventDefault() stops that. But Tab, Enter and letters must keep working. If you stop every key, Tab can’t leave your component, and Enter can’t press a button. So check the key first, and stop only the ones you handle.

How do you add a keyboard shortcut without causing problems?

Listen on the document in an Effect, and clean up. Ignore the key while the user is typing in a box. Don’t take keys the browser, the computer or screen readers use. Let the user turn single-key shortcuts off. Make sure everything the shortcut does can also be done without it.

A strong answer names the W3C rule for single-key shortcuts and the speech input reason. It may also mention e.preventDefault(), so the key isn’t also typed into the box that gets the focus.

Sources

  • WebAIM, Keyboard Accessibility: who uses a keyboard, blind users who “typically use a keyboard”, no outline:none, and no tabindex of 1 or more. WebAIM, “Skip Navigation” Links: hide the link off the screen and show it on focus, not with display:none.
  • W3C WAI, Keyboard Compatibility (“Many people use only the keyboard to navigate websites”) and Physical (a stick held in the mouth, and speech input that “uses the keyboard interface in the background”).
  • W3C ARIA Authoring Practices Guide, Developing a Keyboard Interface: DOM order and the Tab order, focus moving to the body after a delete or a closed dialog, not setting focus on page load, roving tabindex and aria-activedescendant, keys to leave alone, and that on a Mac the Tab order holds “only form elements” by default.
  • W3C ARIA Authoring Practices Guide, Dialog (Modal) Pattern (Tab and Shift+Tab stay inside, the focus goes back to the opener “unless” it no longer exists), Accordion Pattern (the keys, and all buttons in the Tab order) and Listbox Pattern (Home, End, selection that follows focus, and typeahead).
  • W3C WCAG 2.2 Understanding pages: No Keyboard Trap (Escape as a common way out, and “the focus cycle might still include user agent controls”), Character Key Shortcuts (turn off, change, or only on focus, and the speech input example) and the G1 technique for skip links.
  • Radix UI, Accordion: the arrow keys, Home and End between triggers.
  • Apple, Safari Advanced settings: “Press Tab to highlight each item on a webpage”.
  • Firefox’s source code: FindBarChild.sys.mjs starts Quick Find on / unless the event is defaultPrevented, and all.js turns that key on (accessibility.typeaheadfind.manual).
  • MDN: <button> (a click focuses a button, “but Safari does not, by design”), checkVisibility() (false with display: none), tabindex (which tags are focusable, positive values, “only use 0 and -1”, CSS order, and a <div> as a button), :focus-visible, HTMLElement.focus() (focusVisible), inert, aria-activedescendant, keydown (isComposing and keyCode 229) and Keyboard event key values.
  • The HTML standard, Focus: the place a jump to #main starts the next Tab from.
  • react.dev: Manipulating the DOM with Refs (focus with a ref, and flushSync before scrolling to a new item), flushSync (“can significantly hurt performance”), and Common components (the properties of React’s keyboard event, without isComposing).
  • The focus after each step, the listener counts, the inert HTML and the Strict Mode logs come from running React 19.3.0 in jsdom for this post. The Tab orders, focus rings, scrolling, e.key values, the skip link and the <dialog> and inert results come from Chromium 151, Firefox 153 and WebKit 26.5, through playwright-core 1.62.1. The isComposing result is from Chromium only. The Quick Find check is in output/part47-quickfind.txt. The tests ran on Linux, so they don’t show Safari on a Mac.
  • This part follows the Keyboard Navigation kata in react-katas. We changed four things. The kata’s trap reads its stops once, when it opens. Ours reads them on every Tab. The kata’s list moves the focus in an Effect, which also runs on page load. Ours moves it in the key handler. The kata puts outline: 'none' on its items, which hides the ring. And the kata’s “Focus Management” sample says it traps the focus, but it only handles Escape.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.