DEV Community

Rafi
Rafi

Posted on

Roving focus in react with custom hooks

When you have list you may want to move the focus of between list items using arrow keys to make it more accessible and keyboard friendly. You may also want to wrap the focus back to the top when it reaches bottom. You can achieve this using technique called Roving focus.

The idea is really simple when you want to focus next element in the list you make tabIndex of next element 0 and tabIndex of all other list items -1 and you call ref.current.focus() on the ref of that list item so that element comes into view. We also need to maintain the size of the list and index of the current item that is focused so that we can know what element to focus next.

We can write a simple custom hook that does this


import { useCallback, useState, useEffect } from "react";

function useRoveFocus(size) {
  const [currentFocus, setCurrentFocus] = useState(0);

  const handleKeyDown = useCallback(
    e => {
      if (e.keyCode === 40) {
        // Down arrow
        e.preventDefault();
        setCurrentFocus(currentFocus === size - 1 ? 0 : currentFocus + 1);
      } else if (e.keyCode === 38) {
        // Up arrow
        e.preventDefault();
        setCurrentFocus(currentFocus === 0 ? size - 1 : currentFocus - 1);
      }
    },
    [size, currentFocus, setCurrentFocus]
  );

  useEffect(() => {
    document.addEventListener("keydown", handleKeyDown, false);
    return () => {
      document.removeEventListener("keydown", handleKeyDown, false);
    };
  }, [handleKeyDown]);

  return [currentFocus, setCurrentFocus];
}

export default useRoveFocus;

We can use this hook as follows

import React from "react";

import Item from "./Item";
import useRoveFocus from "./useRoveFocus";
import characters from "./onePunchManCharacters";

const List = () => {
  const [focus, setFocus] = useRoveFocus(characters.length);

  return (
    <ul>
      {characters.map((character, index) => (
        <Item
          key={character}
          setFocus={setFocus}
          index={index}
          focus={focus === index}
          character={character}
        />
      ))}
    </ul>
  );
};

export default List;

Since we may want change the focus when element is clicked we pass setFocus function from the hook to the item to change the focus

import React, { useEffect, useRef, useCallback } from "react";

const Item = ({ character, focus, index, setFocus }) => {
  const ref = useRef(null);

  useEffect(() => {
    if (focus) {
      // Move element into view when it is focused
      ref.current.focus();
    }
  }, [focus]);

  const handleSelect = useCallback(() => {
    alert(`${character}`);
    // setting focus to that element when it is selected
    setFocus(index);
  }, [character, index, setFocus]);

  return (
    <li
      tabIndex={focus ? 0 : -1}
      role="button"
      ref={ref}
      onClick={handleSelect}
      onKeyPress={handleSelect}
    >
      {character}
    </li>
  );
};

export default Item;

Here is the repo for the above code and you can find the working sample here

Oldest comments (3)

Collapse
 
valtism profile image
Dan Wood

Thanks Rafi! Very useful

Collapse
 
zhaniartt profile image
Zhaniartt

Thank u very much!

Collapse
 
gaurav5430 profile image
Gaurav Gupta

A few issues with this:

  • adds the event listener to document, it should instead add it on a wrapper component
  • by default focuses the first component, which means browser would scroll the user to the focused component