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:
function ButtonGroup() {
return (
<div>
<button>button 1</button>
<button>button 2</button>
<button>button 3</button>
</div>
)
}
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:
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">
{options.map((button, key) => (
<button
key={key}
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>
)
}
-
We start with a list of button labels
const [options] = useState(['button 1', 'button 2', 'button 3'])
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.function ButtonGroup() { const [options] = useState(['button 1', 'button 2', 'button 3']) + const elements = useRef(new Map<string, HTMLElement>()) return ( <div className="space-x-5"> {options.map((button, key) => ( <button + ref={element => { + if (element) { + elements.current.set(button, element) + } else { + elements.current.delete(button) + } + }} > {button} </button> ))} </div> ) }
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,const currentIndex = options.findIndex(text => text === button)
create
nextIndex
, looping around to 0 if we are on the last button,const nextIndex = currentIndex === options.length - 1 ? 0 : currentIndex + 1
and focus on the related option's element /
setFocusableId
.const nextOption = options.at(nextIndex) if (nextOption) { elements.current.get(nextOption)?.focus() setFocusableId(nextOption) }
-
Then we can update
tabindex
es based onfocusableId
so that our group remembers the most recently focused element.function ButtonGroup() { + const [focusableId, setFocusableId] = useState('button 1') const [options] = useState(['button 1', 'button 2', 'button 3']) return ( <div className="space-x-5"> {options.map((button, key) => ( <button + tabIndex={button === focusableId ? 0 : -1} > {button} </button> ))} </div> ) }
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:
type RovingRootProps = {
options: Options
}
function ButtonGroup({ options }) {
return <RovingRoot options={options} />
}
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:
type RovingRootProps = {
children: ReactNode
}
which results in nice markup like:
function ButtonGroup() {
return (
<RovingRoot>
<Button>button 1</Button>
<Whatever />
<Button>button 2</Button>
<You />
<Button>button 3</Button>
<Like />
</RovingRoot>
)
}
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
:
type BaseButtonProps = {
children: string
focusableId: string
elements: MutableRefObject<Map<string, HTMLElement>>
}
type ButtonProps = BaseButtonProps &
Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps>
function Button(props: ButtonProps) {
return (
<button
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>
)
}
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 elements = Array.from(
ref.current.querySelectorAll<HTMLElement>(
'[data-roving-tabindex-item]',
),
)
const items = Array.from(elements.current)
.sort((a, b) => elements.indexOf(a[1]) - elements.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?.id != null) {
nextItem.element.focus()
setFocusableId(nextItem.id)
}
}
}
return (
<div ref={ref} className="space-x-5">
<Button
focusableId={focusableId}
elements={elements}
onKeyDown={onKeyDown}
>
button 1
</Button>
<span>hello</span>
<Button
focusableId={focusableId}
elements={elements}
onKeyDown={onKeyDown}
>
button 2
</Button>
<span>world</span>
<Button
focusableId={focusableId}
elements={elements}
onKeyDown={onKeyDown}
>
button 3
</Button>
</div>
)
}
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.
type BaseButtonProps = { children: string focusableId: string elements: MutableRefObject<Map<string, HTMLElement>> } type ButtonProps = BaseButtonProps & Omit<ComponentPropsWithoutRef<'button'>, keyof BaseButtonProps> function Button3(props: ButtonProps) { return ( <button 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> ) }
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.
const [focusableId, setFocusableId] = useState('button 1') const elements = useRef(new Map<string, HTMLElement>()) const ref = useRef<HTMLDivElement | null>(null)
-
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.
const elements = Array.from( ref.current.querySelectorAll<HTMLElement>( '[data-roving-tabindex-item]', ), )
This query is run on the wrapper element in ButtonGroup —
ref.current
. -
We create a sorted list of items.
const items: { id: string; element: HTMLElement }[] = Array.from( elements.current, ) .sort((a, b) => elements.indexOf(a[1]) - elements.indexOf(b[1])) .map(([id, element]) => ({ id, element }))
- 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.
const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) if (nextItem?.id != null) { nextItem.element.focus() setFocusableId(nextItem.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.
<Button3 focusableId={focusableId} elements={elements} onKeyDown={onKeyDown} />
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
,type RovingTabindexContext = { elements: MutableRefObject<Map<string, HTMLElement>> } const RovingTabindexContext = createContext<RovingTabindexContext>({ elements: { current: new Map<string, HTMLElement>() }, })
-
update our component to provide the context,
function ButtonGroup() { const [focusableId, setFocusableId] = useState('button 1') const elements = useRef(new Map<string, HTMLElement>()) const ref = useRef<HTMLDivElement | null>(null) /* ... * / return ( + <RovingTabindexContext.Provider value={{ elements }}> <div ref={ref} className="space-x-5"> - <Button3 focusableId={focusableId} elements={elements} onKeyDown={onKeyDown}> + <Button4 focusableId={focusableId} onKeyDown={onKeyDown}> button 1 </Button4> <span>hello</span> - <Button3 focusableId={focusableId} elements={elements} onKeyDown={onKeyDown}> + <Button4 focusableId={focusableId} onKeyDown={onKeyDown}> button 2 </Button4> <span>world</span> - <Button3 focusableId={focusableId} elements={elements} onKeyDown={onKeyDown}> + <Button4 focusableId={focusableId} onKeyDown={onKeyDown}> button 3 </Button4> </div> + </RovingTabindexContext.Provider> ) }
-
and update our button to get the map of elements from our new context.
function Button4(props: ButtonProps2) { + const { elements } = useContext(RovingTabindexContext) return ( <button ref={element => { if (element) { - props.elements.current.set(props.children, element) + elements.current.set(props.children, element) } else { - props.elements.current.delete(props.children) + elements.current.delete(props.children) } }} tabIndex={props.children === props.focusableId ? 0 : -1} data-roving-tabindex-item {...props} > {props.children} </button> ) }
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.
const elements = Array.from( ref.current.querySelectorAll<HTMLElement>( '[data-roving-tabindex-item]', ), ) const items = Array.from(elements.current) .sort((a, b) => elements.indexOf(a[1]) - elements.indexOf(b[1])) .map(([id, element]) => ({ id, element }))
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.
if (isHotkey('right', e)) { const currentIndex = items.findIndex( item => item.element === e.currentTarget, ) const nextItem = items.at( currentIndex === items.length - 1 ? 0 : currentIndex + 1, ) if (nextItem?.id != null) { nextItem.element.focus() setFocusableId(nextItem.id) } }
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.function getOrderedItems() { if (!ref.current) return const elements = Array.from( ref.current.querySelectorAll<HTMLElement>( '[data-roving-tabindex-item]', ), ) return Array.from(elements.current) .sort((a, b) => elements.indexOf(a[1]) - elements.indexOf(b[1])) .map(([id, element]) => ({ id, element })) }
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
+ return ( - <RovingTabindexContext.Provider value={{ elements }}> + <RovingTabindexContext.Provider value={{ elements, getOrderedItems, setFocusableId }}> <div className="space-x-5"> - <Button4 focusableId={focusableId} onKeyDown={onKeyDown}> - button 1 - </Button4> + <Button4 focusableId={focusableId}>button 1</Button4>
-
We update our Button to include the widget-specific logic from our previous
onKeyDown
.function Button4(props: ButtonProps2) { - const { elements } = useContext(RovingTabindexContext) + const { elements, getOrderedItems, setFocusableId } = useContext(RovingTabindexContext) return ( <button + 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?.id != null) { + nextItem.element.focus() + setFocusableId(nextItem.id) + } + } + }} > {props.children} </button> ) }
-
And we update the context types to include the new values
+ type RovingTabindexItem = { + id: string + element: HTMLElement + } type RovingTabindexContext = { + setFocusableId: (id: string) => void + getOrderedItems: () => RovingTabindexItem[] elements: MutableRefObject<Map<string, HTMLElement>> } const RovingTabindexContext = createContext<RovingTabindexContext>({ + setFocusableId: () => {}, + getOrderedItems: () => [], elements: { current: new Map<string, HTMLElement>() }, })
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
function ButtonGroup() { const [focusableId, setFocusableId] = useState('button 1') /* ... */ return ( - <RovingTabindexContext2.Provider value={{ elements, getOrderedItems, setFocusableId }}> + <RovingTabindexContext2.Provider value={{ elements, getOrderedItems, setFocusableId, focusableId }}> <div ref={ref} className="space-x-5"> - <Button4 focusableId={focusableId}>button 1</Button4> + <Button4>button 1</Button4> {/* ... */} </div> </RovingTabindexContext2.Provider> ) }
-
Update our Button API
function Button4(props: ButtonProps2) { - const { elements, getOrderedItems, setFocusableId } = useContext(RovingTabindexContext2) + const { elements, getOrderedItems, setFocusableId, focusableId } = useContext(RovingTabindexContext2) return ( <button {/* ... */} - tabIndex={props.children === props.focusableId ? 0 : -1} + tabIndex={props.children === focusableId ? 0 : -1} > {props.children} </button> ) }
-
And update the RovingTabindexContext to expect a focusableId value.
type RovingTabindexContext2 = { + focusableId: string | null setFocusableId: (id: string) => void getOrderedItems: () => RovingTabindexItem2[] elements: MutableRefObject<Map<string, HTMLElement>> } const RovingTabindexContext2 = createContext<RovingTabindexContext2>({ + focusableId: null, setFocusableId: () => {}, getOrderedItems: () => [], elements: { current: new Map<string, HTMLElement>() }, })
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.
function ButtonGroup() {
/* ... */
return (
<RovingTabindexContext.Provider value={{ elements, getOrderedItems, setFocusableId }}>
<div
ref={ref}
className="space-x-5"
+ 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>
)
}
-
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.if (e.target !== e.currentTarget) return
-
We
getOrderedItems
and return if there aren't any.const orderedItems = getOrderedItems() if (orderedItems.length === 0) return
-
If
focusableId
is initialized, we focus it. Otherwise, we focus the first element.if (focusableId != null) { elements.current.get(focusableId)?.focus() } else { orderedItems.at(0)?.element.focus() }
-
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
.export function ButtonGroup() { + const [isShiftTabbing, setIsShiftTabbing] = useState(false) return ( <RovingTabindexContext.Provider value={{ elements, getOrderedItems, setFocusableId, focusableId, + onShiftTab: function () { + setIsShiftTabbing(true) + }, }} >
-
We update ButtonGroup to toggle the wrapper out of the tab order during a
shift+tab
event.return ( <RovingTabindexContext.Provider value={{/* ... */}}> <div - tabIndex={0} + tabIndex={isShiftTabbing ? -1 : 0} > }
-
In our Button we can then set this value in the
onKeyDown
ofshift+tab
.return ( <button onKeyDown={(e) => { + if (isHotkey('shift+tab', e)) { + onShiftTab() + return + } /* ... */
-
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.return ( <RovingTabindexContext.Provider value={{/* ... */}}> <div tabIndex={isShiftTabbing ? -1 : 0} + onBlur={() => setIsShiftTabbing(false)} > }
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
function useRovingTabindex(id: string) {
const {
elements,
getOrderedItems,
setFocusableId,
focusableId,
onShiftTab,
} = useContext(RovingTabindexContext)
return {
getOrderedItems,
isFocusable: focusableId === id,
getRovingProps: <T extends ElementType>(
props: ComponentPropsWithoutRef<T>,
) => ({
...props,
ref: (element: HTMLElement | null) => {
if (element) {
elements.current.set(id, element)
} else {
elements.current.delete(id)
}
},
onKeyDown: (e: KeyboardEvent) => {
props?.onKeyDown?.(e)
if (isHotkey('shift+tab', e)) {
onShiftTab()
return
}
},
onFocus: (e: FocusEvent) => {
props?.onFocus?.(e)
setFocusableId(id)
},
['data-roving-tabindex-item']: true,
tabIndex: focusableId === id ? 0 : -1,
}),
}
}
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.
const { elements, getOrderedItems, setFocusableId, focusableId, onShiftTab, } = useContext(RovingTabindexContext)
-
We pass along
getOrderedItems
and a new prop callisFocusable
.return { getOrderedItems, isFocusable: focusableId === id }
-
We create a generic prop getter so that the props returned match the DOM element they are applied to.
getRovingProps: <T extends ElementType>(props: ComponentPropsWithoutRef<T>) => ({
-
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
.onFocus: (e: FocusEvent) => { props?.onFocus?.(e) setFocusableId(id) },
We can just
focus
the node and it will automaticallysetFocusableId
.
With useRovingTabindex
our Button get's much simpler.
export function Button(props: ButtonProps) {
- const { elements, getOrderedItems, setFocusableId, focusableId, onShiftTab } =
- useContext(RovingTabindexContext)
+ const { getOrderedItems, getRovingProps } = useRovingTabindex(props.children)
return (
<button
- 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
- }
- /* ... moved into the onKeyDown below */
- }}
- tabIndex={props.children === focusableId ? 0 : -1}
- data-roving-tabindex-item
+ {...getRovingProps<'button'>({
+ 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>
)
}
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.
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)
- }
+ nextItem?.element.focus()
}
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
.
getRovingProps: <T extends ElementType>(props: ComponentPropsWithoutRef<T>) => ({
...props,
+ onMouseDown: (e: MouseEvent) => {
+ props?.onMouseDown?.(e)
+ setFocusableId(id)
+ },
onFocus: (e: FocusEvent) => {
props?.onFocus?.(e)
setFocusableId(id)
},
/* ... */
}),
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.
onMouseDown: (e: MouseEvent) => {
props?.onMouseDown?.(e)
+ if (e.target !== e.currentTarget) return
setFocusableId(id)
},
onKeyDown: (e: KeyboardEvent) => {
props?.onKeyDown?.(e)
+ if (e.target !== e.currentTarget) return
if (isHotkey('shift+tab', e)) {
onShiftTab()
return
}
},
onFocus: (e: FocusEvent) => {
props?.onFocus?.(e)
+ if (e.target !== e.currentTarget) return
setFocusableId(id)
},
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.
type RovingTabindexRootBaseProps<T> = {
children: ReactNode | ReactNode[]
as?: T
}
type RovingTabindexRootProps<T extends ElementType> =
RovingTabindexRootBaseProps<T> &
Omit<ComponentPropsWithoutRef<T>, keyof RovingTabindexRootBaseProps<T>>
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:
-export function ButtonGroup({
+export function RovingTabindexRoot<T extends ElementType>({
children,
as,
...props
}: RovingTabindexRootProps<T>) {
+ const Component = as ?? 'div'
const [focusableId, setFocusableId] = useState<string | null>(null)
const [isShiftTabbing, setIsShiftTabbing] = useState(false)
const elements = useRef(new Map<string, HTMLElement>())
function getOrderedItems() {
/* ... unchanged */
}
return (
<RovingTabindexContext.Provider
value={/* ... unchanged */}
>
- <div
+ <Component
{...props}
onFocus={e => {
+ props?.onFocus?.(e)
if (e.target !== e.currentTarget || isShiftTabbing) return
const orderedItems = getOrderedItems()
/* ... unchanged */
}}
onBlur={e => {
+ props?.onBlur?.(e)
setIsShiftTabbing(false)
}}
{/* ... */}
>
{children}
- </div>
+ </Component>
</RovingTabindexContext.Provider>
)
}
-
Since our
as
prop is optional we can coalse it todiv
const Component = as ?? 'div'
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
onFocus={e => { + props?.onFocus?.(e) if (e.target !== e.currentTarget || isShiftTabbing) return const orderedItems = getOrderedItems() /* ... unchanged */ }}
This prevents event handlers from silently being overwritten. A pain to debug :shaking fist:.
Now let's recreate ButtonGroup using our new API
export function ButtonGroup() {
return (
<RovingTabindexRoot className="space-x-5" as="div">
<Button>button 1</Button>
<Button>button 2</Button>
<Button>button 3</Button>
</RovingTabindexRoot>
)
}
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
.type RovingTabindexRootBaseProps<T> = { children: ReactNode | ReactNode[] as?: T + valueId?: string }
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.
export function RovingTabindexRoot<T extends ElementType>({ children, + valueId, as, ...props }: RovingTabindexRootProps<T>) {
-
And then we update the
onFocus
to selectvalueId
only whenfocusableId
isnull
.onFocus={e => { props?.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 if (valueId != null) { + elements.current.get(valueId)?.focus() } else { orderedItems.at(0)?.element.focus() } }}
Let's also update our example to indicate which button is selected:
type BaseButtonProps = {
children: string
+ isSelected: boolean
}
/* ... */
export function Button(props: ButtonProps) {
/* ... */
return (
<button
{...getRovingProps<'button'>({
onKeyDown: e => {
/* ... */
},
+ className: props.isSelected
+ ? 'bg-black text-white'
+ : 'bg-white text-black',
...props,
})}
>
{props.children}
</button>
)
}
export function ButtonGroup() {
+ const [valueId, setValueId] = useState('button 2')
return (
- <RovingTabindexRoot className="space-x-5" as="div">
+ <RovingTabindexRoot className="space-x-5" as="div" valueId={valueId}>
<Button
+ isSelected={valueId === 'button 1'}
+ onClick={() => setValueId('button 1')}
>
button 1
</Button>
{/* ... */}
</RovingTabindexRoot>
)
}
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
const currentIndex = items.findIndex(item => item.element === e.currentTarget)
const nextItem = items.at(
currentIndex === items.length - 1 ? 0 : currentIndex + 1,
)
We can do something similar for prev
node
const currentIndex = items.findIndex(item => item.id === id)
return items.at(currIndex === 0 ? -1 : currIndex - 1)
and we can put them both into functions call getNextFocusableId
andgetPrevFocusableId
;
export function getNextFocusableId(
items: RovingTabindexItem[],
id: string,
): RovingTabindexItem | undefined {
const currIndex = items.findIndex(item => item.id === id)
return orderedItems.at(currIndex === items.length - 1 ? 0 : currIndex + 1)
}
export function getPrevFocusableId(
items: RovingTabindexItem[],
id: string,
): RovingTabindexItem | undefined {
const currIndex = items.findIndex(item => item.id === id)
return items.at(currIndex === 0 ? -1 : currIndex - 1)
}
Then we can use them like so:
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,
- )
+ 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()
- }
},
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.