Tables and data grids in the web are everywhere – luckily, most are for data viewing, but sometimes, you'll end up needing edit functionality in a grid layout, like an Excel spreadsheet. Unfortunately, things immediately become a little trickier once a grid is made up of interactive elements like inputs. The tricky thing I want to talk about today in these interfaces is focus management.
To illustrate, let's draw up the simplest of spreadsheets. We'll grab some inputs and arrange them in a grid. I've applied some styling so it looks and feels like your average spreadsheet software (except you can't edit this one).
Sheet #1
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
You can use the control buttons to tab through the table, or use the auto-play functionality if you're lazy! The currently focused cell will be highlighted in Green.
Now, try selecting input 29 and see what happens!
The problem
If you're on a desktop or tablet using a mouse, trackpad or touchscreen, you're in luck: Just click the cell you want to target (in this case number 29) and you're done. That's quite a nice user experience! But for keyboard users, the mechanics used here quickly get unfeasible.
Try it yourself: Go back up, reset the demo, and then use only the keyboard buttons to navigate to cell 29. It should take you at least 28 presses of the tab key to get there. That's a horrible experience!
With more than a couple cells, there are two problems for keyboard users:
- Getting to the n-th cell requires n presses of the tab key
- Skipping the group of interactive elements requires all of the elements to be traversed. If you're above the grid and want to focus on a link that's below the grid, you need to go through all the inputs inside.
Both of these are no bueno. But there's a solution, and if you've ever used Numbers or Excel, you will already see where this is going.
Tab Roving to the rescue
We can improve this issue with a technique called a roving tab index, or tab roving for short.
With tab roving, when the focus reaches your data grid, instead of using the tab key to navigate between cells, you can instead use your arrow keys. Tabbing forward and backward moves the focus from and to the grid, but will not have an effect on which cell is currently active. Basically, the whole grid acts as a single interactive element.
You can think of this as another layer of focus management. The page has many elements that can be focused and tabbed through at the top level, and our grid has itself many focusable elements in it that can be stepped through when the element has the page focus.
Let's see this much improved grid navigation in action:
Sheet #1
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Feels more like Excel already, doesn't it? We also now only require 3 key presses to get from cell number 1 to cell number 29. That's an 89% improvement!
Focus Management
So how does this work behind the scenes? For this to make sense, let's do a quick recap on how "tabbability" works in the web in general. By default, only certain elements can receive focus via the tab key, for example inputs, selects, buttons and links. Other elements, like divs, cannot be tabbed to. Elements are tabbed through in the order they appear in in the source code, which we'll call the natural tab sequence.
We can adjust this behavior by using the tabindex attribute on HTML elements. It controls in what order elements receive focus when the tab key is pressed. It can receive three types of values:
- tabindex="-1", which removes elements from the browsers's natural tab sequence so that they cannot be tabbed to.
- tabindex="0", which adds elements to the natural tab sequence. Adding this to a div element makes it focusable and tabbable.
- tabindex=">=1": Values larger than zero create a new tab sequence in ascending order of the values, which will be traversed prior to the natural tab sequence of the remaining elements.
Outlining a solution
Now that we've established how tab sequences are handled in the browser, let's revisit our tab roving example from above and see how it works. To recap, we want the whole group of cells to act as a single focusable element, and for the focus inside to be manageable using arrow keys. How can we do this using only the tabindex mechanics described?
The idea is to only have a single element in the natural tab sequence, while all other elements are made un-tabbable using tabindex="-1". Then, while our single element has focus, we can track arrow key presses and move the tab index to the adjacent inputs.
In the following example, each cell shows its assigned tabindex attribute value as you navigate between the cells:
Sheet #1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
You can see how only a single cell is in the natural tab sequence at a time. When an arrow key is pressed, the old cell is removed from the tab index, and the new cell is added.
This creates a new problem, though: Elements with tabindex="-1" aren't tabbable, but they might still be focusable. So when we move the tab index to the new cell programatically, the focus stays on the old cell. Therefore, we also need to manually focus the new cell using HTMLElement.focus().
So in summary, we need to:
- Keep track of the currently focused element in our group of elements (in our case cells)
- Handle key presses on that active element
- When a key press is detected:
- Determine the newly focused element
- Remove the currently focused element from the natural tab sequence
- Add the newly focused element to the natural tab order, and focus it
Implementing a solution
The approach described above works in all frontend scenarios independent of the framework. In our case though, we'll use React to implement it.
Let's start by writing some boilerplate and rendering a grid of inputs:
/* Correct mod implementation */ function mod(n: number, m: number) { return ((n % m) + m) % m; } const GRID_SIZE = 5; export default function TabRovingGrid() { const gridArray = new Array(GRID_SIZE * GRID_SIZE).fill(0); return ( <> <a href="#">Example link</a> <div> {gridArray.map((_, index) => ( <input type="text" key={index} /> ))} </div> <a href="#">Example link</a> </> ); }According to our plan, we need to keep track of the currently focused element. We can do this either by using IDs or indices. In this case, we're going to keep track of the currently focused cell index. Let's add some state to do this:
const [focusedIndex, setFocusedIndex] = useState(0);We can initialize this state to the first cell, because we need to have one cell focusable initially. We'll also make sure that only the focused cell can be tabbed to:
<input type="text" tabIndex={focusedIndex === index ? 0 : -1} />Next, we need to handle key presses on the cells. Let's add a handler which determines the next active index:
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const { key } = event; const totalItems = GRID_SIZE * GRID_SIZE; let newIndex = -1; if (key === "ArrowRight") { event.preventDefault(); newIndex = mod(focusedIndex + 1, totalItems); } else if (key === "ArrowLeft") { event.preventDefault(); newIndex = mod(focusedIndex - 1, totalItems); } else if (key === "ArrowDown") { event.preventDefault(); newIndex = mod(focusedIndex + GRID_SIZE, totalItems); } else if (key === "ArrowUp") { event.preventDefault(); newIndex = mod(focusedIndex - GRID_SIZE, totalItems); } if (newIndex >= 0) { setFocusedIndex(newIndex); } };Take a second to understand what's happening here. For each arrow key, are are calculating the next active index using the dimensions of our grid and the currently focused index. If we have determined a new index, we update our focused index. Since we're using React, the UI will re-render automatically.
We're also calling Event.preventDefault() in our logic to prevent other browser logic from interfering with our own. The arrow keys, for example, scroll the viewport by default, which we don't want to happen while we're navigating between cells.
Upon updating the focused index, we need to programmatically focus the next cell. In React, we need refs to do this. For small to medium grid sizes, the easiest option is to just keep a list of references to all the cells, and then address the cell to be focused as necessary. Let's create a list of refs:
const cellRefs = useRef<Array<HTMLInputElement | null>>( gridArray.map((_) => null), );...and assign each ref to its corresponding cell:
<input type="text" tabIndex={focusedIndex === index ? 0 : -1} onKeyDown={handleKeyDown} ref={(el) => { cellRefs.current[index] = el; }} />Now that we have a reference to all the cells, we can make one more small change to our onKeyDown handler, where we not only set the focused index, but also actually focus it:
// ... if (newIndex >= 0) { setFocusedIndex(newIndex); cellRefs.current[newIndex]?.focus(); }The last change we need to make is to account for the user manually clicking another cell. In that case, we want to update the focusedIndex value so that on the next keypress, the calculations use the correct index. Let's set the focused index in the onFocus event:
<input // ... onFocus={() => setFocusedIndex(index)} />And voila! We have tab roving! You can play with the solution we just implemented below. I've applied some minimal styles for the grid layout, but the code is the same:
More details
Of course, you don't have to stop there. There are many other tweaks you could make to make the grid more accessible and powerful to use for keyboard users. For example, you could add support for shortcuts like cmd + ← or cmd + → to jump to the start and end of a row, respectively. The more of these small shortcuts you add, the easier navigating large lists will become.
Another key aspect to consider is what I refer to as edge wrapping: This is the behavior of the grid when you reach the end of a row or column and keep moving the focus "over the edge". In our grid example above, the wrapping is looping in the vertical direction and wraps over in the row direction. That means that when you use the up and down arrow keys, you will always stay in the same column, whereas using the left and right arrow keys will get you to the previous and next row when you go over the edges of the current row.
There aren't any rules for this behavior, but this combination is the most intuitive to me personally. You can experiment with other settings below:
Sheet #1
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Edge Wrapping
X AxisY Axis
Other use cases
I've illustrated this technique using a two-dimensional grid, but it can be helpful in a lot of situations.
A great example are mega menus, where you might not want to tab through all the available options, but rather tab through the top-level menu items and use arrow keys to drill down into the child menu items if wanted. For example, Stripe does this on their home page.
Custom numerical inputs with buttons to increase or decrease the value are also a common use case. In general, the technique described here can be used in lots of situations where you want to avoid unnecessarily focusing too many elements that can be thought of as a group.
I hope you've enjoyed this little accessibility-focused excursion. Big thanks to Sam for giving early feedback on this post!