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!
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.
-
Initialization:
- For each list, make the
first
item focusable by settingtabindex=0
- Make all other items not focusable by setting
tabindex=-1
- For each list, make the
-
Arrow Key Navigation:
- Listen for
keydown
events - ArrowDown or ArrowRight: move focus to the next item in the list
- ArrowUp or ArrowLeft: move focus to the previous item in the list
- 🔥Bonus: vim keybindings
- Listen for
-
Tab Navigation:
- Listen for
keydown
events - Tab: move focus to the next list
- Shift-Tab: move focus to the previous list
- Listen for
-
Tab : Next list
-
Shift + Tab : Previous list
-
↓ or → : Next item
-
↑ or ← : Previous item
-
ctrl + n or ctr + j : Next Item
-
ctrl + p or ctr + k : Previous Item
Code
- To separate
state
fromUI
, we’ll create a hook - We need to:
- keep track of which element/index in the list has focus
- avoid focusing the element on page load
- handle keyboard events
- update
activeIndex
state accordingly
- update
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
}
}
- On the
UI
side, for each list, we need to:- connect a keyboard event handler to the list
- set the correct
tabIndex
for each list item - update the focus index if the list item gains focus through a mouse click
// 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>
)
}