React roving tabindex

Mar 19, 2023

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 tabs 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.
  • 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 tabindexes 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>
    )
}
  1. 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.

  2. 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.

  3. 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)
    }
    
  4. Then we can update tabindexes based on focusableId 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:

  1. 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!

  2. 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)
    
  3. 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

  1. 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.

  2. 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.
  3. 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.

  1. We createContext,

    type RovingTabindexContext = {
        elements: MutableRefObject<Map<string, HTMLElement>>
    }
    
    const RovingTabindexContext = createContext<RovingTabindexContext>({
        elements: { current: new Map<string, HTMLElement>() },
    })
    
  2. 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>
        )
    }
    
  3. 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.

  1. 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.

  2. 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, the right 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.

  1. 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 roving tabindexes.

  2. We update our context to pass both getOrderedItems and setFocusableId

    +    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>
    
  3. 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>
        )
    }
    
  4. 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 from getOrderedItems.

focusableId

The only thing remaining in props is focusableId. Let's change that.

  1. 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>
        )
    }
    
  2. 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>
        )
    }
    
  3. 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>
    )
}
  1. We make the wrapper focusable by adding a tabindex={0} to it

  2. 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+tabing 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

  1. 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)
    +                },
                }}
            >
    
  2. 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}
                >
    }
    
  3. In our Button we can then set this value in the onKeyDown of shift+tab.

    return (
        <button
            onKeyDown={(e) => {
    +           if (isHotkey('shift+tab', e)) {
    +                onShiftTab()
    +                 return
    +           }
                /* ... */
    
  4. This then toggles tabIndex to -1 on the wrapper div, and our focus leaves ButtonGroup altogether.

  5. The onBlur of our Button occurs, bubbles up to ButtonGroup, and there we reset ButtonGroup's tabIndex 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:

  1. We consume the same context as before.

    const {
        elements,
        getOrderedItems,
        setFocusableId,
        focusableId,
        onShiftTab,
    } = useContext(RovingTabindexContext)
    
  2. We pass along getOrderedItems and a new prop call isFocusable.

    return {
        getOrderedItems,
        isFocusable: focusableId === id
    }
    
  3. 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>) => ({
    
  4. We put all roving tabindex related props in the return of our prop getter.

  5. We call setFocusableId in onFocus so that you no longer have to focus a node and setFocusableId.

    onFocus: (e: FocusEvent) => {
        props?.onFocus?.(e)
        setFocusableId(id)
    },
    

    We can just focus the node and it will automatically setFocusableId.

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 tabindexes 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.

  1. A user clicks a button
  2. onClick occurs
  3. onClick results in onFocus
  4. onFocus calls setFocusableId

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:

  1. Since RovingTabindexRoot wraps its children in an element, let's add an as prop to give the user an option of which element to use.
  2. Since the existing ButtonGroup has onBlur and onFocus 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.

  1. BaseProps & Omit<ComponentPropsWithoutRef, keyof BaseProps>

    This code allows our BaseProps to redefine properties that are already defined with ComponentPropsWithoutRef

  2. RovingTabindexRootProps<T extends ElementType>

    Ensures our types are generic and that the type T is part of ElementType, 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>
    )
}
  1. Since our as prop is optional we can coalse it to div

    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.

  2. We need to orchestrate onFocus and onBlur

    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:

  1. 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 prop valueId because it will likely be a derived identifier for this value.

  2. Destructure it from props.

    export function RovingTabindexRoot<T extends ElementType>({
        children,
    +    valueId,
        as,
        ...props
    }: RovingTabindexRootProps<T>) {
    
  3. And then we update the onFocus to select valueId only when focusableId is null.

    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 .

Subscribe to the newsletter

A monthly no filler update.

Contact me at