Keyboard Navigation with Roving Index

⏱︎ 3 min read

🎯 What Problem Are We Trying to Solve?

Switching between multiple lists using the tab key and navigating within them using the arrow keys is accessible and very satisfying!

roving index diagram

The Roving Index Technique

The roving index technique involves dynamically managing focus within lists, where only one element in a list is focusable at a time. As the user navigates through the list, the focus moves with them.


Code

export const useRover = <T extends HTMLElement>(listLength: number) => {
  const [activeIndex, setActiveIndex] = useState(0)
  const activeEl = useRef<T>()

  // didMount is used to avoid focusing the element on page load
  // We want to mark the activeIndex but not focus it yet
  const didMount = useRef(false)
  useEffect(() => {
    didMount.current = true
  }, [])

  // focus prev item 
  // loop around the list 
  const prev = () => {
    setActiveIndex((prev) => {
      return activeIndex > 0 ? prev - 1 : listLength - 1
    })
  }

  // focus next item
  // loop around the list
  const next = () => {
    setActiveIndex((prev) => {
      return prev < listLength - 1 ? prev + 1 : 0
    })
  }

  // handle keyboard events and  only preventDefault on expected keys
  const handleKeyDown = useCallback(
    (ev: KeyboardEvent) => {
      if (isNextKey(ev)) {
        ev.preventDefault()
        return next()
      }

      if (isPrevKey(ev)) {
        ev.preventDefault()
        return prev()
      }
    },
    [next, prev]
  )

  // We'll call this on each list item ref
  const focuseEl = useCallback((el: T) => {
    if (didMount.current) {
      if (el !== activeEl.current) {
        activeEl.current = el
        el.focus()
      }
    }
  }, [activeIndex])

  return {
    activeIndex,
    handleKeyDown,
    focuseEl,
    focusIndex: (index: number) => setActiveIndex(index)
  }
}

const isNextKey = (ev: KeyboardEvent) => {
  switch (ev.key) {
    case "ArrowDown":
    case "ArrowRight":
      return true
    // vim'ish
    case "n":
    case "j":
      if (ev.ctrlKey) {
        return true
      }
    default:
      return false
  }
}
// Render a RovingList for each list

const LISTS = [
  ["apple", "banana", "orange", "grape", "peach", "pineapple"],
  ["red", "orange", "yellow", "green", "blue", "purple"],
  ["dog", "cat", "bird", "fish", "lion", "tiger", "whale"]
]

function app () {
  return <List lists={LISTS} />
}


function List(props: { lists: string[][] }) {
  return props.lists.map((list, index) => (
    <RovingList key={index} list={list} />
  )
}

function RovingList({ list }: { list: string[] }) {
  const { activeIndex, handleKeyDown, focuseEl, focusIndex } = useRover<HTMLLIElement>(
    list.length
  )

  return (
    <ul onKeyDown={handleKeyDown}>
      {list.map((item, index) => {
        const tabIndex = index === activeIndex ? 0 : -1

        return (
          <li
            key={item}
            tabIndex={tabIndex}
            // update the focus state when a user clicks on this item
            onFocus={() => focusIndex(index)}
            ref={tabIndex === 0 ? focuseEl : undefined}
          >
            {item}
          </li>
        )
      })}
    </ul>
  )
}