In this post, I'll introduce the roving tabindex
and then we will create one in React by adding keyboard navigation to a list of buttons.
Here is a demo of the group of buttons we will recreate together.
(Focus on one of the buttons, navigate with the right
and left
keys, and select with space
)
import isHotkey from 'is-hotkey' import { clsx } from 'clsx' import { ComponentPropsWithoutRef, useState } from 'react' import { useRovingTabindex, RovingTabindexRoot, getPrevFocusableId, getNextFocusableId } from './roving-tabindex' type BaseButtonProps = { children: string isSelected: boolean } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { getOrderedItems, getRovingProps } = useRovingTabindex( props.children, ) return ( <button {...getRovingProps<'button'>({ className: clsx( 'border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black', props.isSelected ? 'bg-black text-white' : 'bg-white text-black', ), onKeyDown: e => { props?.onKeyDown?.(e) const items = getOrderedItems() let nextItem: RovingTabindexItem | undefined if (isHotkey('right', e)) { nextItem = getNextFocusableId(items, props.children) } else if (isHotkey('left', e)) { nextItem = getPrevFocusableId(items, props.children) } nextItem?.element.focus() }, ...props, })} > {props.children} </button> ) } export function ButtonGroup() { const [valueId, setValueId] = useState('button 2') return ( <RovingTabindexRoot className="space-x-5 flex" as="div" valueId={valueId}> <Button isSelected={valueId === 'button 1'} onClick={() => setValueId('button 1')} > button 1 </Button> <Button isSelected={valueId === 'button 2'} onClick={() => setValueId('button 2')} > button 2 </Button> <Button isSelected={valueId === 'button 3'} onClick={() => setValueId('button 3')} > button 3 </Button> </RovingTabindexRoot> ) } export default function App() { return ( <div className="flex h-screen justify-center items-center"> <ButtonGroup /> </div> ) }
What is a roving tabindex
?
Here is an example:
As the arrow keys are pressed within this ToggleGroup, the focus moves and the tabindex
is switched between 0
and -1
based on the current focus.
When the user tab
s to and from our ToggleGroup, the correct button is focused because it's the only button with an 0
tabindex
.
A roving tabindex
is a way of tracking focus within a group elements so that the group remembers the last focused item as you tab
to and from it.
When do you need a roving tabindex
?
You might need a roving tabindex
if:
- You need a widget that isn't supported by the HTML5 spec.
- Treeview and Layout Grid are two examples of widgets that aren't supported natively.
- The semantic version has limitations.
- The Togglegroup is a Radiogroup with a completely different UI.
- The Combobox is a
<select />
with search and autocomplete.
Both of these situations could be summarized as "the functionality I want isn't provided by the platform, so I need to build or rebuild it myself."
Why do roving tabindex
es exist?
The web was created with the assumption that desktop apps were where real work gets done.
As apps move to the browser, many desktop UI patterns are still not yet natively supported on the web.
A roving tabindex
is the key to adding keyboard navigation to custom widgets.
Introducing our example
Imagine you needed to create a group of buttons where the right
key moved the focus to the next button.
In its most basic form our "widget" would look something like this:
How can we move the user focus move between the buttons?
More specifically, how can we know what element to .focus()
and text to select in the onKeyDown
?
Element order from a list
The simplest roving tabindex
in React looks something like this:
-
We start with a list of button labels
This gives us an explicit order. Finding the next button is just finding the index to the current button and adding 1.
-
We need to get the related
HTMLElement
so that we can call.focus()
Let's store a
Map<string, HTMLElement>
of button label to button element and use a ref callback to set/clean up this state as buttons get mounted and unmounted.This gives us access to button elements based on the text they contain.
-
In the
onKeyDown
, we find the index of the currently focused node,create
nextIndex
, looping around to 0 if we are on the last button,and focus on the related option's element /
setFocusableId
. -
Then we can update
tabindex
es based onfocusableId
so that our group remembers the most recently focused element.
And with that, we have a roving tabindex
.
Our widget rememebers what item was last focused and focus moves directly to that option when you tab into the group.
Feel free to try it for yourself:
import isHotkey from 'is-hotkey' import { useState, useRef, KeyboardEvent } from 'react' export default function ButtonGroup() { const [focusableId, setFocusableId] = useState('button 1') const [options] = useState(['button 1', 'button 2', 'button 3']) const elements = useRef(new Map<string, HTMLElement>()) return ( <div className="space-x-5 flex h-screen justify-center items-center"> {options.map((button, key) => ( <button key={key} className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black" onKeyDown={(e: KeyboardEvent) => { if (isHotkey('right', e)) { const currentIndex = options.findIndex( text => text === button, ) const nextIndex = currentIndex === options.length - 1 ? 0 : currentIndex + 1 const nextOption = options.at(nextIndex) if (nextOption) { elements.current.get(nextOption)?.focus() setFocusableId(nextOption) } } }} tabIndex={button === focusableId ? 0 : -1} ref={element => { if (element) { elements.current.set(button, element) } else { elements.current.delete(button) } }} > {button} </button> ))} </div> ) }
Downsides
Our current roving tabindex
comes with a limitation.
Our options must be held in state so that we can find which node is "next".
This constraint results in APIs like this:
This API type is ok high up in the tree of your app, but as you get down into the leaves you want components that are optimized for composability. Custom UI widgets are by definition leaf components.
Instead of getting our order from react state, let's get the order from the DOM, and communicate between our components with context.
This will allow us to have a composable children
API for our component like:
which results in nice markup like:
where children can opt-in to the roving tabindex
via context.
Element order from the DOM
Here is an updated demo that derives element order from querySelectorAll
:
Let's walk through what was updated:
-
We created a new Button component This keeps us from c/p'ing the ref callback, and it sets us up to move state into context in the next section.
The API for this component is temporarily garbage 🗑️. Why would the prop API for our Button have a ref to a
Map
of elements? No worries we will fix that soon! -
We no longer have a list of options.
Just which id is focusable, the mapping from button label to HTML element, and a ref to a wrapper node.
-
We added a
data-roving-tabindex-item
attribute to our "items."This attribute allows us to query the DOM for the order in a way that doesn't depend on a specific structure.
Then in our onKeyDown
-
We get the list of buttons from the DOM.
This query is run on the wrapper element in ButtonGroup —
ref.current
. -
We create a sorted list of items.
- Line 1:
Array.from
returns the key-value pairs of our Map as a tuple - Line 2: We
.sort
those pairs based on the order of elements in the dom - Line 3: We
.map
these tuples to an object for ease of use.
- Line 1:
-
Now we can find the current index, increment it, and then focus/select the element/id.
We are now finding the current index based on the
currentTarget
of our event.
Here is a demo of what this looks like:
import isHotkey from 'is-hotkey' import { MutableRefObject, ComponentPropsWithoutRef, useState, useRef, KeyboardEvent, } from 'react' type BaseButtonProps = { children: string focusableId: string elements: MutableRefObject<Map<string, HTMLElement>> } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { return ( <button className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black" ref={element => { if (element) { props.elements.current.set(props.children, element) } else { props.elements.current.delete(props.children) } }} tabIndex={props.children === props.focusableId ? 0 : -1} data-roving-tabindex-item {...props} > {props.children} </button> ) } export default function ButtonGroup() { const [focusableId, setFocusableId] = useState('button 1') const elements = useRef(new Map<string, HTMLElement>()) const ref = useRef<HTMLDivElement | null>(null) function onKeyDown(e: KeyboardEvent) { if (isHotkey('right', e)) { if (!ref.current) return const elementsFromDOM = Array.from( ref.current.querySelectorAll<HTMLElement>('[data-roving-tabindex-item]'), ) const items = Array.from(elements.current) .sort( (a, b) => elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), ) .map(([id, element]) => ({ id, element })) const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) if (nextItem != null) { nextItem.element.focus() setFocusableId(nextItem.id) } } } return ( <div ref={ref} className="space-x-5 flex h-screen justify-center items-center"> <Button focusableId={focusableId} elements={elements} onKeyDown={onKeyDown} > button 1 </Button> <Button focusableId={focusableId} elements={elements} onKeyDown={onKeyDown} > button 2 </Button> <Button focusableId={focusableId} elements={elements} onKeyDown={onKeyDown} > button 3 </Button> </div> ) }
You might feel like we have taken a step back. Our component is still tightly coupled and the number of lines has only increased.
In the next few sections, we'll untangle this by moving our state into context and creating some abstraction for as minimal impact on our ButtonGroup as possible.
From props to context
Our button has three props that need to be moved into context.
elements
Let's start by providing the Map of saved elements via context. Passing that as a prop is the strangest part of our current setup.
-
We
createContext
, -
update our component to provide the context,
-
and update our button to get the map of elements from our new context.
onKeyDown
The next thing I want to break down and move into context is our onKeyDown
.
It's currently doing two things.
-
It gets a list of items in the order they are found in the DOM.
Finding an element's position within its siblings is a reusable part of our roving
tabindex
. So let's keep this logic in the ButtonGroup component. -
It uses the list of items to implement keyboard navigation.
Binding the
right
key to the next element is useful for our current widget. But not all widgets will do this specific behavior. For example, in a treeview, theright
key can toggle a subtree to the open state.
Since this logic is specific to the current widget, let's move it into the Button component.
With that distinction in mind let's break down the onKeyDown
.
-
We create
getOrderedItems
in ButtonGroup.This function holds is reusable logic from our
onKeyDown
that will be useful in all our rovingtabindex
es. -
We update our context to pass both
getOrderedItems
andsetFocusableId
-
We update our Button to include the widget-specific logic from our previous
onKeyDown
. -
And we update the context types to include the new values
Note: the new type
RovingTabindexItem
defines the item that will be returned fromgetOrderedItems
.
focusableId
The only thing remaining in props is focusableId
. Let's change that.
-
We pass focusableId via context
-
Update our Button API
-
And update the RovingTabindexContext to expect a focusableId value.
Phew! That was a lot of diffs.
Our roving tabindex
is in a much better place.
import isHotkey from 'is-hotkey' import { MutableRefObject, createContext, ComponentPropsWithoutRef, useContext, useState, useRef, } from 'react' type RovingTabindexItem = { id: string element: HTMLElement } type RovingTabindexContext = { focusableId: string | null setFocusableId: (id: string) => void getOrderedItems: () => RovingTabindexItem[] elements: MutableRefObject<Map<string, HTMLElement>> } const RovingTabindexContext = createContext<RovingTabindexContext>({ focusableId: null, setFocusableId: () => {}, getOrderedItems: () => [], elements: { current: new Map<string, HTMLElement>() }, }) type BaseButtonProps = { children: string } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { elements, getOrderedItems, setFocusableId, focusableId } = useContext(RovingTabindexContext) return ( <button className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black" ref={element => { if (element) { elements.current.set(props.children, element) } else { elements.current.delete(props.children) } }} onKeyDown={e => { if (isHotkey('right', e)) { const items = getOrderedItems() const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) if (nextItem != null) { nextItem.element.focus() setFocusableId(nextItem.id) } } }} tabIndex={props.children === focusableId ? 0 : -1} data-roving-tabindex-item {...props} > {props.children} </button> ) } export default function ButtonGroup() { const [focusableId, setFocusableId] = useState('button 1') const elements = useRef(new Map<string, HTMLElement>()) const ref = useRef<HTMLDivElement | null>(null) function getOrderedItems() { if (!ref.current) return [] const elementsFromDOM = Array.from( ref.current.querySelectorAll<HTMLElement>('[data-roving-tabindex-item]'), ) return Array.from(elements.current) .sort( (a, b) => elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), ) .map(([id, element]) => ({ id, element })) } return ( <RovingTabindexContext.Provider value={{ elements, getOrderedItems, setFocusableId, focusableId }} > <div ref={ref} className="space-x-5 flex h-screen justify-center items-center"> <Button>button 1</Button> <Button>button 2</Button> <Button>button 3</Button> </div> </RovingTabindexContext.Provider> ) }
There are some edge cases I want to handle before we jump into making this thing reusable.
tab
and shift+tab
You may have noticed that focusableId
is of type string | null
, but in all the code snippets I have initialized it to "button 1".
Most UI patterns specify that "if nothing is previously focused" -> "focus the first element." But requiring this initialization goes against the "derive it from the DOM" pattern we have been following.
If we don't initialize focusableId
all our buttons are tabindex={-1}
.
This prevents focus from entering the group and onKeyDown
will never be fired.
focusing the first element when focusableId
is null
We can solve this problem by making our wrapper div focusable and using the onFocus
to "pass" the focus to the first child.
-
We make the wrapper focusable by adding a
tabindex={0}
to it -
Then we create an
onFocus
handler-
We check that this
onFocus
came from this div being focused and that it didn't bubble up from a children element. -
We
getOrderedItems
and return if there aren't any. -
If
focusableId
is initialized, we focus it. Otherwise, we focus the first element.
-
import isHotkey from 'is-hotkey' import { MutableRefObject, createContext, ComponentPropsWithoutRef, useContext, useState, useRef, } from 'react' type RovingTabindexItem = { id: string element: HTMLElement } type RovingTabindexContext = { focusableId: string | null setFocusableId: (id: string) => void getOrderedItems: () => RovingTabindexItem[] elements: MutableRefObject<Map<string, HTMLElement>> } const RovingTabindexContext = createContext<RovingTabindexContext>({ focusableId: null, setFocusableId: () => {}, getOrderedItems: () => [], elements: { current: new Map<string, HTMLElement>() }, }) type BaseButtonProps = { children: string } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { elements, getOrderedItems, setFocusableId, focusableId } = useContext(RovingTabindexContext) return ( <button className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black" ref={element => { if (element) { elements.current.set(props.children, element) } else { elements.current.delete(props.children) } }} onKeyDown={e => { if (isHotkey('right', e)) { const items = getOrderedItems() const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) if (nextItem != null) { nextItem.element.focus() setFocusableId(nextItem.id) } } }} tabIndex={props.children === focusableId ? 0 : -1} data-roving-tabindex-item {...props} > {props.children} </button> ) } export function ButtonGroup() { const [focusableId, setFocusableId] = useState<string | null>(null) const elements = useRef(new Map<string, HTMLElement>()) const ref = useRef<HTMLDivElement | null>(null) function getOrderedItems() { if (!ref.current) return [] const elementsFromDOM = Array.from( ref.current.querySelectorAll<HTMLElement>('[data-roving-tabindex-item]'), ) return Array.from(elements.current) .sort( (a, b) => elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), ) .map(([id, element]) => ({ id, element })) } return ( <RovingTabindexContext.Provider value={{ elements, getOrderedItems, setFocusableId, focusableId, }} > <div ref={ref} className="space-x-5 flex" tabIndex={0} onFocus={e => { if (e.target !== e.currentTarget) return const orderedItems = getOrderedItems() if (orderedItems.length === 0) return if (focusableId != null) { elements.current.get(focusableId)?.focus() } else { orderedItems.at(0)?.element.focus() } }} > <Button>button 1</Button> <Button>button 2</Button> <Button>button 3</Button> </div> </RovingTabindexContext.Provider> ) } export default function App() { return ( <div className="space-y-5 flex flex-col h-screen justify-center items-center"> <button className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black"> previous interactive element </button> <ButtonGroup /> </div> ) }
This solution created another problem though. shift+tab
ing is now broken. (Jump back into the previous example and try for yourself!)
Hitting shift+tab
moves focus to this wrapper div, and its onFocus then passes the focus back to the child node.
enabling shift+tab
to move focus through our wrapper div
To fix this we can toggle the tabindex
of our wrapper div between the onKeyDown
of our Button and the onBlur
of our ButtonGroup
-
In ButtonGroup, we can create some state for representing when the user presses
shift+tab
. -
We update ButtonGroup to toggle the wrapper out of the tab order during a
shift+tab
event. -
In our Button we can then set this value in the
onKeyDown
ofshift+tab
. -
This then toggles
tabIndex
to-1
on the wrapper div, and our focus leaves ButtonGroup altogether. -
The
onBlur
of our Button occurs, bubbles up to ButtonGroup, and there we reset ButtonGroup'stabIndex
state.
shift+tab
is now working!
import isHotkey from 'is-hotkey' import { MutableRefObject, createContext, ComponentPropsWithoutRef, useContext, useState, useRef, } from 'react' type RovingTabindexItem = { id: string element: HTMLElement } type RovingTabindexContext = { focusableId: string | null setFocusableId: (id: string) => void onShiftTab: () => void getOrderedItems: () => RovingTabindexItem[] elements: MutableRefObject<Map<string, HTMLElement>> } const RovingTabindexContext = createContext<RovingTabindexContext>({ focusableId: null, setFocusableId: () => {}, onShiftTab: () => {}, getOrderedItems: () => [], elements: { current: new Map<string, HTMLElement>() }, }) type BaseButtonProps = { children: string } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { elements, getOrderedItems, setFocusableId, focusableId, onShiftTab, } = useContext(RovingTabindexContext) return ( <button className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black" ref={element => { if (element) { elements.current.set(props.children, element) } else { elements.current.delete(props.children) } }} onKeyDown={e => { if (isHotkey('shift+tab', e)) { onShiftTab() return } if (isHotkey('right', e)) { const items = getOrderedItems() const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) if (nextItem != null) { nextItem.element.focus() setFocusableId(nextItem.id) } } }} tabIndex={props.children === focusableId ? 0 : -1} data-roving-tabindex-item {...props} > {props.children} </button> ) } export function ButtonGroup() { const [focusableId, setFocusableId] = useState<string | null>(null) const [isShiftTabbing, setIsShiftTabbing] = useState(false) const elements = useRef(new Map<string, HTMLElement>()) const ref = useRef<HTMLDivElement | null>(null) function getOrderedItems() { if (!ref.current) return [] const elementsFromDOM = Array.from( ref.current.querySelectorAll<HTMLElement>('[data-roving-tabindex-item]'), ) return Array.from(elements.current) .sort( (a, b) => elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]), ) .map(([id, element]) => ({ id, element })) } return ( <RovingTabindexContext.Provider value={{ elements, getOrderedItems, setFocusableId, focusableId, onShiftTab: function () { setIsShiftTabbing(true) }, }} > <div ref={ref} className="space-x-5 flex" tabIndex={isShiftTabbing ? -1 : 0} onFocus={e => { if (e.target !== e.currentTarget || isShiftTabbing) return const orderedItems = getOrderedItems() if (orderedItems.length === 0) return if (focusableId != null) { elements.current.get(focusableId)?.focus() } else { orderedItems.at(0)?.element.focus() } }} onBlur={() => setIsShiftTabbing(false)} > <Button>button 1</Button> <Button>button 2</Button> <Button>button 3</Button> </div> </RovingTabindexContext.Provider> ) } export default function App() { return ( <div className="space-y-5 flex flex-col h-screen justify-center items-center"> <button className="border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black"> previous interactive element </button> <ButtonGroup /> </div> ) }
With that, we can finally make this roving tabindex
reusable.
Reusability part 1: useRovingTabindex
Let's start by moving everything in our button that is related to a roving tabindex
into a hook called useRovingTabindex
Our roving tabindex
uses a ref callback, data attribute, and event handlers.
All these things need to be added to components that are part of our roving tabindex
, but requiring each of these components to orchestrate these properties with the properties they are already using is a tall order.
Thankfully we can use the prop getter pattern to do the heavy lift and make our API as unintrusive as possible.
In our useRovingTabindex
:
-
We consume the same context as before.
-
We pass along
getOrderedItems
and a new prop callisFocusable
. -
We create a generic prop getter so that the props returned match the DOM element they are applied to.
-
We put all roving
tabindex
related props in the return of our prop getter. -
We call setFocusableId in
onFocus
so that you no longer have tofocus
a node andsetFocusableId
.We can just
focus
the node and it will automaticallysetFocusableId
.
With useRovingTabindex
our Button get's much simpler.
The only logic that is in our button is the logic related to "the pattern" it implements, and what happens when the right
key is pressed.
Since our roving tabindex
has the new onFocus
(point 5 from above), we no longer have to call setFocusableId
manually.
I am planning to use this abstraction on many components and have already found some edge cases. We will handle them below:
Forcing focus onClick for Safari
Roving tabindex
es change based on both mouse and keyboard interactions.
With the addition of the onFocus
above, we simplified our API by no longer having to call setFocusableId
in your onKeyDown
.
- A user clicks a button
onClick
occursonClick
results inonFocus
onFocus
callssetFocusableId
Unfortunately, Safari doesn't call onFocus
when you onClick
.
We can mimic this behavior by adding a setFocusableId manually to an onMouseDown
in our useRovingTabindex
.
Nested elements
I'm currently working on a treeview series where nodes are nested within each other.
When I tried using the roving tabindex
above, the events bubbled up to the highest element, preventing navigation into subtrees.
We can add e.stopPropagation()
to the events in our prop getter, but a less invasive way of handling this is checking if the event came from the current element.
e.target
is the origin element that started the event. e.currentTarget
is the current element that the event is bubbling through.
So checking if e.target !== e.currentTarget
is a great way of skipping over events that are bubbling up from descendants.
Reusability part 2: RovingTabindexRoot
The ButtonGroup thankfully needs less work than our Button did. It's entirely roving tabindex
logic. So we'll repurpose it as a reusable component called RovingTabindexRoot
.
There are two things we need to change:
- Since
RovingTabindexRoot
wraps its children in an element, let's add anas
prop to give the user an option of which element to use. - Since the existing ButtonGroup has
onBlur
andonFocus
events, let's make sure those events are orchestrated.
Let's start with some types.
Using the as
prop pattern allows us to specify which HTML element a component renders.
-
BaseProps & Omit<ComponentPropsWithoutRef, keyof BaseProps>
This code allows our
BaseProps
to redefine properties that are already defined withComponentPropsWithoutRef
-
RovingTabindexRootProps<T extends ElementType>
Ensures our types are generic and that the type
T
is part ofElementType
, which is an export from React including string names of all the element types.
Here is the main diff of what we change to make this component reusable:
-
Since our
as
prop is optional we can coalse it todiv
I am using
Component
because all react components must start with a Capital letter, but it could be any capital letter word. -
We need to orchestrate
onFocus
andonBlur
This prevents event handlers from silently being overwritten. A pain to debug :shaking fist:.
Now let's recreate ButtonGroup using our new API
Soo simple!
import isHotkey from 'is-hotkey' import { ComponentPropsWithoutRef } from 'react' import { RovingTabindexRoot, useRovingTabindex } from './roving-tabindex'; type BaseButtonProps = { children: string } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { getOrderedItems, getRovingProps } = useRovingTabindex( props.children, ) return ( <button {...getRovingProps<'button'>({ className: 'border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black', onKeyDown: e => { props?.onKeyDown?.(e) if (isHotkey('right', e)) { const items = getOrderedItems() const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) nextItem?.element.focus() } }, ...props, })} > {props.children} </button> ) } export function ButtonGroup() { return ( <RovingTabindexRoot className="space-x-5 flex" as="div"> <Button>button 1</Button> <Button>button 2</Button> <Button>button 3</Button> </RovingTabindexRoot> ) } export default function App() { return ( <div className="flex h-screen justify-center items-center"> <ButtonGroup /> </div> ) }
Selected state
In the intro, I laid out the premise that you will likely use a roving tabindex
to create custom widgets.
Then when we were talking about tab
functionality I said:
Most UI patterns specify that if nothing is "previously focused" focus the "first element."
Well most UI patterns go a step further and the focus order upon entering a roving tabindex
should be:
"focus previously focused" -> "focus current value" -> "focus first element"
Note the concept in the middle.
We covered the "first element" case in tab, but our roving tabindex
has no concept of the "current value".
To change that:
-
We add valueId to the prop type of
RovingTabindexRoot
.ButtonGroup will likely be a controlled component with a
value
prop. To avoid any confusion I'm calling our new propvalueId
because it will likely be a derived identifier for this value. -
Destructure it from props.
-
And then we update the
onFocus
to selectvalueId
only whenfocusableId
isnull
.
Let's also update our example to indicate which button is selected:
import isHotkey from 'is-hotkey' import { clsx } from 'clsx' import { useState, ComponentPropsWithoutRef } from 'react' import { useRovingTabindex, RovingTabindexRoot } from './roving-tabindex' type BaseButtonProps = { children: string isSelected: boolean } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { getOrderedItems, getRovingProps } = useRovingTabindex( props.children, ) return ( <button {...getRovingProps<'button'>({ className: clsx( 'border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black', props.isSelected ? 'bg-black text-white' : 'bg-white text-black', ), onKeyDown: e => { props?.onKeyDown?.(e) if (isHotkey('right', e)) { const items = getOrderedItems() const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) nextItem?.element.focus() } }, ...props, })} > {props.children} </button> ) } export function ButtonGroup() { const [valueId, setValueId] = useState('button 2') return ( <RovingTabindexRoot className="space-x-5 flex" as="div" valueId={valueId}> <Button isSelected={valueId === 'button 1'} onClick={() => setValueId('button 1')} > button 1 </Button> <Button isSelected={valueId === 'button 2'} onClick={() => setValueId('button 2')} > button 2 </Button> <Button isSelected={valueId === 'button 3'} onClick={() => setValueId('button 3')} > button 3 </Button> </RovingTabindexRoot> ) } export default function App() { return ( <div className="flex h-screen justify-center items-center"> <ButtonGroup /> </div> ) }
Selectors
Our Button's onKeyDown
will get pretty clunky if we add many more keyboard shortcuts.
For readability's sake let's create some selectors to simplify it.
getNext
and getPrev
We already have the logic for finding the next node
We can do something similar for prev
node
and we can put them both into functions call getNextFocusableId
andgetPrevFocusableId
;
Then we can use them like so:
With the help of our selectors, the implementation is easier to read and both right
and left
keys work!
import isHotkey from 'is-hotkey' import { clsx } from 'clsx' import { ComponentPropsWithoutRef, useState } from 'react' import { useRovingTabindex, RovingTabindexRoot, getPrevFocusableId, getNextFocusableId } from './roving-tabindex' type BaseButtonProps = { children: string isSelected: boolean } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> export function Button(props: ButtonProps) { const { getOrderedItems, getRovingProps } = useRovingTabindex( props.children, ) return ( <button {...getRovingProps<'button'>({ className: clsx( 'border-2 border-black px-2 pt-0.5 focus:outline-dashed focus:outline-offset-4 focus:outline-2 focus:outline-black', props.isSelected ? 'bg-black text-white' : 'bg-white text-black', ), onKeyDown: e => { props?.onKeyDown?.(e) const items = getOrderedItems() let nextItem: RovingTabindexItem | undefined if (isHotkey('right', e)) { nextItem = getNextFocusableId(items, props.children) } else if (isHotkey('left', e)) { nextItem = getPrevFocusableId(items, props.children) } nextItem?.element.focus() }, ...props, })} > {props.children} </button> ) } export function ButtonGroup() { const [valueId, setValueId] = useState('button 2') return ( <RovingTabindexRoot className="space-x-5 flex" as="div" valueId={valueId}> <Button isSelected={valueId === 'button 1'} onClick={() => setValueId('button 1')} > button 1 </Button> <Button isSelected={valueId === 'button 2'} onClick={() => setValueId('button 2')} > button 2 </Button> <Button isSelected={valueId === 'button 3'} onClick={() => setValueId('button 3')} > button 3 </Button> </RovingTabindexRoot> ) } export default function App() { return ( <div className="flex h-screen justify-center items-center"> <ButtonGroup /> </div> ) }
Depending on the pattern you might need getParentFocusableId
, getLastFocusableId
, or getTypeaheadFocusable
.
If so I have created versions here that you can use.
Thanks for coming along for the ride.
I love hearing feedback. If you have any don't hesitate to .
Links
- Demos
- Github
- Components I have written about that use this roving tabindex
- I found this pattern by reading through Radix UI. Before I create a custom widget I check if Radix has something I can use. Time is short.