Select Page

Using Both Tab And Arrow Keys For Keyboard Navigation

Ben Nadel
Published: June 11, 2024

I must admit that, historically, when thinking about keyboard-based navigation on a website, I’ve really only considered the Tab key for moving around and the Space and Enter keys for activation. The other day, however, I noticed something very interesting on the GitHub site: the Tab key was skipping over large swaths of buttons—buttons which, it turns out, can only be accessed using the ArrowLeft and ArrowRight keys. This kind of blew my mind!

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Essentially, GitHub was creating groups of buttons that could be entered and exited with the Tab key; but, once focus was inside one of these groups, the entirety of the button-set could only be explored using the arrow keys to move in between the various buttons.

This interaction model forced me to step back and consider my stance on keyboard navigation. I’ve always wanted to make my user interfaces as accessible as possible; but, I haven’t given much thought to the experience of keyboard navigation. GitHub’s approach feels like it strikes a great balance: providing full page access while, at the same time, minimizing the depth of any given tangential navigation path.

To explore this idea, I’m going to create two groups of button. The Tab key will move focus from one group of buttons to the next. And then, the ArrowLeft and ArrowRight keys will move focus to the previous button and the next button, respectively, within the currently-focused group.

The first button in each group will have tabindex="0". This means that the first button in each group can receive Tab key navigation. Each subsequent button in the given group will have tabindex="-1". This means that the element can still receive focus programmatically; but, that the browser will skip over this element in response to the Tab key.

The arrow keys can be used to programmatically shift focus within a group. As the focus is shifted, so too is the tabindex="0". As each button becomes focused, it (programmatically) becomes the future ingress for that group. This way, if the user tabs-out of the group and then tabs-back-into the group, the previously-focused button will regain focus.

To drive this demo, I’m using Alpine.js for the keyboard bindings. Each group of buttons represents an Alpine.js component (x-data="tabGroup"). The groups don’t have to know about each other because inter-group navigation is implemented natively by the browser (ie, tabbing from one focusable DOM element to the next).

For in-group navigation, I’m using event-delegation to handle all event-bindings at the group-level. This keeps the HTML markup looking a bit nicer and less noisy for the demo. Here is a snippet of one such group:

Notice that only the first button has tabindex="0". This button represents the ingress to the group. The arrow keys then invoke Alpine.js component methods to programmatically move the focus (and the tabindex="0") from button to button.

Here’s my Alpine.js component:

function tabGroup() {
	var host = this.$el;
	// Return the public API of the component scope.
	return {
		moveTabIndexToButton: moveTabIndexToButton,
		moveToNextButton: moveToNextButton,
		moveToPrevButton: moveToPrevButton
	};
	// ---
	// PUBLIC METHODS.
	// ---
	/**
	* I move the active tabIndex (0) to the target button. This way, when the user clicks
	* a button, this becomes the button that can also be activated via the Tab key.
	*/
	function moveTabIndexToButton( event ) {
		var targetButton = event.target.closest( "button" );
		// Since we're using event-delegation on the host, it's possible that the click
		// event isn't targeting a button. In that case, ignore the event.
		if ( ! targetButton ) {
			return;
		}
		for ( var button of getAllButtons() ) {
			button.tabIndex = -1;
		}
		targetButton.tabIndex = 0;
	}
	/**
	* I move the focus and active tabIndex (0) to the next button in the set of buttons
	* contained within the host element.
	*/
	function moveToNextButton( event ) {
		// Prevent any default browser behaviors (such as scrolling the viewport).
		event.preventDefault();
		// Note: Technically, we're using event-delegation for the arrow keys. However,
		// since no other elements (other than our demo buttons) can be focused within the
		// host element, we can be confident that this was triggered by a button.
		var targetButton = event.target.closest( "button" );
		var allButtons = getAllButtons();
		var currentIndex = allButtons.indexOf( targetButton );
		// Get the NEXT button; or, loop around to the front of the collection.
		var futureButton = (
			allButtons[ currentIndex + 1 ] ||
			allButtons[ 0 ]
		);
		targetButton.tabIndex = -1;
		futureButton.tabIndex = 0;
		futureButton.focus();
	}
	/**
	* I move the focus and active tabIndex (0) to the previous button in the set of
	* buttons contained within the host element.
	*/
	function moveToPrevButton( event ) {
		// Prevent any default browser behaviors (such as scrolling the viewport).
		event.preventDefault();
		// Note: Technically, we're using event-delegation for the arrow keys. However,
		// since no other elements (other than our demo buttons) can be focused within the
		// host element, we can be confident that this was triggered by a button.
		var targetButton = event.target.closest( "button" );
		var allButtons = getAllButtons();
		var currentIndex = allButtons.indexOf( targetButton );
		// Get the PREVIOUS button; or, loop around to the back of the collection.
		var futureButton = (
			allButtons[ currentIndex - 1 ] ||
			allButtons[ allButtons.length - 1 ]
		);
		targetButton.tabIndex = -1;
		futureButton.tabIndex = 0;
		futureButton.focus();
	}
	// ---
	// PRIVATE METHODS.
	// ---
	/**
	* I get all the buttons in the host element (as a proper array).
	*/
	function getAllButtons() {
		return Array.from( host.querySelectorAll( "button" ) );
	}
}

If we now load the demo, I can jump from button group to button group without having to iterate over every button:

User navigating in between two button groups using Tab key; and then, using the ArrowLeft and ArrowRight keys to move from button to button within a single group.

As you can see, the Tab key moves in between the two button groups, skipping over all the subsequent buttons within a group. But, once a group is focused, the ArrowLeft and ArrowRight keys provide the horizontal navigation.

I love this from an experiential standpoint. But, I fear that it isn’t intuitive. Meaning, users have a deep-rooted understanding that the Tab key moves focus around the DOM; but, there’s no established standard of using arrow keys to also move focus. In fact, I only discovered this GitHub behavior through trial-and-error (after I noticed that the Tab key was skipping interactive elements).

That said, I’d rather have this interaction model—even if I have to stumble-upon it—rather than having to tab over an endless array of irrelevant buttons.

this morning’s “Go Make Things” newsletter, Chris Ferdinandi talks about working “with the grain” of the web platform. That is, leveraging the native features of the web instead of working against them. In light of that, I probably should have used Alpine.js to progressively add the tabindex="-1" to the DOM during component initialization. As it happens now, with the tabindex properties hard-coded in the HTML, it means that if the JavaScript fails to load, those buttons can’t be accessed via the keyboard. It would have been better to default to full Tab navigation; and then, only progressively enhanced the experience with the ArrowLeft and ArrowRight keys.

But, I had already deployed the demo to GitHub before this occurred to me. And it felt like it would make a better talking point to call it out.

Want to use code from this post?
Check out the license.


https://bennadel.com/4667

Source: www.bennadel.com