Create Custom Keyboard Navigation for Grid Items
Tags: react, a11y, typescript
Published: 2022-10-16 15:16:04


Recently I have faced a challenge of setting up a custom keyboard navigation inside a long list of files. They are displayed both in grid and list view. As I haven't found the exact solution when googling, here is an article explaining what I have learned. Thanks to [Ryan Mulligan's article](https://ryanmulligan.dev/blog/project-keyboard-navigation/) for inspiration.

Prerequisite: basic knowledge of React.

[Check out the working project demo site](https://custom-keyboard-navigation.netlify.app/) to see what we are building.

##Setting up starter code

[Get starter GitHub repository](https://github.com/eruj22/custom-keyboard-navigation-starter) to follow along as we dive into the code.You can clone the repository or download the whole file. 

Open code it in your favorite editor and run commands `yarn install && yarn start`. This will run the code in your local environment.

##Start of the project

Now we are ready to start coding. Let’s first add title and show items. All the necessary data for the items is in the file `items.json`. We can import them into the `App` and loop through each one.

```
import "./App.css";
import items from "./items.json";

function App() {
  return (
    <main className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          return <article>single item</article>;
        })}
      </section>
    </main>
  );
}

export default App;
```

Currently we just display the same text for each item. We can change that in order to display image and checkbox. Open the component `Item` inside `src/components/Item.tsx` and update the component.

```
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
};

function Item(props: ItemProps) {
  const { description, name, url } = props;

  return (
    <div className="item">
      <input className="input" type="checkbox" name={name} id={name} />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;
```

We also need to import the `Item` into the `App` and pass all the props to it. Here we spread all props instead of manually adding each one.

```
import "./App.css";
import Item from "./components/Item";
import items from "./items.json";

function App() {
  return (
    <main className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          return <Item {...item} />;
        })}
      </section>
    </main>
  );
}

export default App;
```

Now we can add some styles to both components to make the app look better.

In the `App.css` we make style main and display items inside a grid.

```
.main {
  margin: auto;
  max-width: 1000px;
  text-align: center;
}

.items {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
}
```

In the `Item.css` we style image and absolutely position checkbox.

```
.item {
  position: relative;
  height: 150px;
  border-radius: 10px;
  overflow: hidden;
  cursor: pointer;
  border: 2px solid lightgray;
}

.image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.input {
  position: absolute;
  width: 18px;
  height: 18px;
  cursor: pointer;
}
```

What we want to do next is to select checkbox when we click on the image. This makes whole user experience much better than just clicking on small checkbox. 

To do that we need to add state inside `Item` to track if checkbox is checked. We add `onClick` handler to the whole item and manually set checkbox state via `checked` property.
 
```
import { useState } from "react";
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
};

function Item(props: ItemProps) {
  const { description, name, url } = props;
  const [isChecked, setIsChecked] = useState(false);

  return (
    <div
      className={isChecked ? "item checked" : "item"}
      onClick={() => setIsChecked(!isChecked)}
    >
      <input
        className="input"
        type="checkbox"
        name={name}
        id={name}
        checked={isChecked}
      />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;
```

In the code above we have added new class name `checked`, so we also need to add it to the `Item.css`.

```
.checked {
  border: 2px solid rgb(36, 36, 174);
}
```

##Add custom keyboard navigation

Currently, when you tab through the app, the focus changes from one checkbox to the other. This is still okay if you have small number of items. Imagine displaying 500 items and trying to navigate via keyboard to the next section of the app. Would you be happy to tab through 500 items just to get to the next section? Probably not. Luckily, there is a better way to navigate through long lists of items.

The idea is simple. You only have one checkbox with `tabIndex=0`  (can be focused) and all the others have `tabIndex=-1` (ignored by keyboard navigation). Then we programmatically change `tabIndex` for each item based on which arrow the user clicks.

Let's get back to our code. Inside the `App` we add `ref` to the `main` so that we only listen for keyboard clicks inside it. We also add state for tracking cursor to know which item is currently focused.

Inside the first `useEffect` we add event listener. On each keyboard button press the function `handleKey` is fired. Inside it we look for any arrow key press and based on it we modify state of the cursor. To simplify things a bit the `numberOfColumns` is hard coded.

In the second `useEffect` we find the correct checkbox inside `main` based on it’s name and then focus it.

```
import { keyboardKey } from "@testing-library/user-event";
import { useEffect, useRef, useState } from "react";
import "./App.css";
import Item from "./components/Item";
import items from "./items.json";

function App() {
  const itemsRef = useRef<HTMLElement>(null);
  const [cursor, setCursor] = useState(1);
  const numberOfColumns = 4;
  const totalNumberOfFiles = items.length;

  useEffect(() => {
    const handleKey = (event: keyboardKey) => {
      if (event.key === "ArrowRight") {
        setCursor((prevCursor) => {
          if (prevCursor === totalNumberOfFiles) {
            return totalNumberOfFiles;
          }

          return prevCursor + 1;
        });
      }

      if (event.key === "ArrowLeft") {
        setCursor((prevCursor) => {
          if (prevCursor === 0) {
            return 0;
          }

          return prevCursor - 1;
        });
      }

      if (event.key === "ArrowDown") {
        setCursor((prevCursor) => {
          if (prevCursor + numberOfColumns > totalNumberOfFiles) {
            return prevCursor;
          }

          return prevCursor + numberOfColumns;
        });
      }

      if (event.key === "ArrowUp") {
        setCursor((prevCursor) => {
          if (prevCursor - numberOfColumns < 0) {
            return prevCursor;
          }

          return prevCursor - numberOfColumns;
        });
      }
    };

    if (itemsRef.current) {
      const currentCursor = itemsRef.current;

      currentCursor.addEventListener("keyup", handleKey);

      return () => currentCursor.removeEventListener("keyup", handleKey);
    }
  }, [totalNumberOfFiles, numberOfColumns]);

  useEffect(() => {
    if (itemsRef.current) {
      const selectCursor = itemsRef.current.querySelector(
        `input[name='item ${cursor}']`
      );
      (selectCursor as HTMLInputElement)?.focus();
    }
  }, [cursor]);

  return (
    <main ref={itemsRef} className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          const tabIndex = cursor === item.id ? 0 : -1;

          return <Item {...item} tabIndex={tabIndex} />;
        })}
      </section>
    </main>
  );
}

export default App;
```

The last thing that we need add is `tabIndex` to the `Item`

```
import { useState } from "react";
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
  tabIndex: number;
};

function Item(props: ItemProps) {
  const { description, name, url, tabIndex } = props;
  const [isChecked, setIsChecked] = useState(false);

  return (
    <div
      className={isChecked ? "item checked" : "item"}
      onClick={() => setIsChecked(!isChecked)}
    >
      <input
        className="input"
        type="checkbox"
        name={name}
        id={name}
        checked={isChecked}
        tabIndex={tabIndex}
      />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;
```

---

Thank you for reading this article. Hope you have learned something new.

[Link to the finished project GitHub repository](https://github.com/eruj22/custom-keyboard-navigation-finished )