At first glance, the toggle group is just buttons smooshed together. But making those buttons keyboard accessible following the radio group ARIA pattern is the tricky part.
In this post, we will
- create a component API with basic mouse interactivity
- introduce and implement a roving
tabindex
with keyboard shortcuts - and cap it all off by talking through the ARIA guidelines.
Here's what we are making:
import { useState } from "react"; import { ToggleGroup } from "./toggle-group" export default function App() { const [favoriteFruit, changeFavoriteFruit] = useState<string | null>("banana") return ( <div className="flex h-screen justify-center items-center"> <ToggleGroup.Root value={favoriteFruit} onChange={changeFavoriteFruit} aria-label="What is your favorite fruit?" > <ToggleGroup.Button value="strawberry" className="px-2"> Strawberry 🍓 </ToggleGroup.Button> <ToggleGroup.Button value="banana" className="px-2"> Banana 🍌 </ToggleGroup.Button> <ToggleGroup.Button value="apple" className="px-2"> Apple 🍏 </ToggleGroup.Button> </ToggleGroup.Root> </div> ) }
Component API
The toggle group is a visual variant of the radio group pattern.
It is a great single-click alternative to the combobox and should be used when there are few enough selectable options.
I'll be making it a controlled
component so that it can be used in forms and toolbars.
The API should be composable with other primitives from my UI kit, so I'll break it down into Root
and Button
sub-components.
We are creating a ToggleGroup
like the first option:
There is nothing wrong with the second API option, but it's not composable.
Imagine someone was trying to use our ToggleGroup
with icon buttons.
Now they have to extend it to take an aria-label
in the options array.
We can avoid problems like this one by keeping our API open.
Creating the Root
component
Our Root
component doesn't hold the state of our input but it will pass it along nicely with context
.
With context created, we can make the Root
component render the context provider and spread all other props onto the <div />
that is wrapping our children
.
ComponentPropsWithoutRef
is a generic TS type that ship with React.
ComponentPropsWithoutRef<'div'>
represents all the props that a <div />
expects.
It is great for typing components where the props are spread onto an element.
If you redefine any of keys from ComponentPropsWithoutRef
you might get a type mismatch.
To work around this I am Omit
ing the keys of RootBaseProps
from ComponentPropsWithoutRef
before creating the intersection type.
For more info on ComponentPropsWithoutRef
I found this blog post helpful ↗
Creating the Button
component
Ok onto the Button
— the consumer of our context.
-
We use the
BaseProps
+ComponentPropsWithoutRef
type combo to type our component like a<button />
. -
We then consume the context for the
selectedValue
andonChange
. -
We add some styles.
bg-slate-200 p-1
for base background color and paddingfirst:rounded-l last:rounded-r
to round the first and last<button />
shover:bg-slate-300
to add a hover stateselectedValue === value && 'bg-slate-300'
to add a selected state
-
We disabled the default focus in favor of a border alternative
outline-none border-2 border-transparent focus:border-slate-400
.I mainly did this because the elements are adjacent to each other, which causes some outline clipping.
Note: I set a border width (
border-2
) and make it transparent (border-transparent
) so that there is no resizing when the element is focused. -
And finally we add an event handler for
onClick
of eachButton
to trigger theonChange
of ourToggleGroup
.
Just like that — our ToggleGroup
works with the mouse.
import { useState } from "react"; import { ToggleGroup } from "./toggle-group" export default function App() { const [favoriteFruit, changeFavoriteFruit] = useState<string | null>("banana") return ( <div className="flex h-screen justify-center items-center"> <ToggleGroup.Root value={favoriteFruit} onChange={changeFavoriteFruit} aria-label="What is your favorite fruit?" > <ToggleGroup.Button value="strawberry" className="px-2"> Strawberry 🍓 </ToggleGroup.Button> <ToggleGroup.Button value="banana" className="px-2"> Banana 🍌 </ToggleGroup.Button> <ToggleGroup.Button value="apple" className="px-2"> Apple 🍏 </ToggleGroup.Button> </ToggleGroup.Root> </div> ) }
Keyboard shortcut concepts
Before we jump into implementing our keyboard shortcuts, I want to pose some questions that will ensure we are on the same page.
What are the different selection types?
-
The first type of selection is "selection follows focus":
As the user traverses the toggle group focus and selection move together.
-
The other type of selection is distinct from focus:
Focus is controlled with arrows (and other keys based on what pattern you are following), and selection is controlled with
space
.
What ARIA pattern does the ToggleGroup
follow?
The toggle group follows the radio group pattern, which means that — aside from usage within a toolbar — its selection follows focus.
Don't be confused by incorrect implementations out there. I didn't end up finding a correct one :P
- The Radix toggle group has distinct focus/selection regardless of whether it is in a toolbar or not.
- The Ant Design toggle group has selection follow focus on arrow key, but also allows you to jump between different toggle groups without using
tab
. - and Material UI's toggle group just treats the component as a group of
<button/>
s.
What shortcuts will we be implementing?
These are coming straight from the documentation of the radio group pattern.
up
/left
arrows should move focus/selection to theprevious button
down
/right
arrows should move focus/selection to thenext button
space
should select the focused element if it isn't already selectedtab
/shift+tab
should select the first value unless a value is already specified
Keyboard shortcut implementation
To create keyboard shortcuts for next button
and previous button
we need to know the order our buttons are rendered.
If we had chosen API option two with an options prop like this -> options=[{value: 'strawberry', label: 'Strawberry 🍓'}, ...]
, then we would have such an order.
Instead, we will have to get this data from the DOM. If we know the order of elements in the DOM and which values correspond to each element, we can derive the order of values.
We will accomplish this by
- creating a map of values to elements
- getting the order of elements in the DOM
- and sorting a list of values based on the element order.
Creating a map of values to elements
The first step is associating DOM elements with values in our toggle group.
-
We create a mapping of value to element
This state is only used in event handlers and shouldn't cause rerenders so
useRef
is perfect. -
We create some callbacks for the
Button
s to register with theirRoot
-
And then in our
Button
component we use a callback ref to update the mappingSince callback refs are called with
null
between value changes, this code willderegister
before it reregister
s again with a new element.
Getting the order of elements and sorting our list of values
When reading Radix's implementation of a roving tabindex
I found that they query based on a data attribute.
I'll be adding a similar data attribute to the Button
.
Now I can query the ref of the wrapper element in my Root
for that data attribute.
-
We create a new callback for getting an ordered list of items
Item
s here is what I am referring to an object with a value and it's related element. -
In that callback we can query the
Button
s within ourRoot
-
And then derive the order of
Item
s from the order of elements in the DOM.
Creating the next button
keyboard shortcut
Jumping back into the Button
I can now add an onKeyDown
event handler to trigger navigation.
-
We get the list of ordered
Item
s -
Create a variable to hold the
nextItem
that will be focused/selected -
Find the index of the currently focused item
-
If it doesn't exist, set
nextItem
to be the first item in our ordered list -
In the case of
right
ordown
keys, setnextItem
to be the item positioned atcurrIndex + 1
-
Now we can check if
nextItem
was set. If it was,focus
the related element andonChange
with the related value
Creating the previous button
keyboard shortcut
With the next button
shortcut in place adding in previous button
is quite easy.
Composing keyboard events
Let's also handle any event handlers passed to our Root
and Button
components.
A prop interface is like a contract that the component offers.
Since we used ComponentPropsWithoutRef
, it's important to compose the event handlers and keep our side of the bargain.
This will ensure that if someone wants to trigger an event on a specific Button
like so:
they won't run into any surprises.
Preventing scroll on up
and down
arrows
One final thing we need to do is prevent the default scroll on the up
and down
keys. preventDefault
is perfect for doing just that.
Here is a look at our component so far:
import { useState } from "react"; import { ToggleGroup } from "./toggle-group" export default function App() { const [favoriteFruit, changeFavoriteFruit] = useState<string | null>("banana") return ( <div className="flex flex-col space-y-10 h-screen justify-center items-center"> <button className="text-white bg-[rgb(171,135,255)] px-2 py-1 rounded-md">focusable element</button> <ToggleGroup.Root value={favoriteFruit} onChange={changeFavoriteFruit} aria-label="What is your favorite fruit?" > <ToggleGroup.Button value="strawberry" className="px-2"> Strawberry 🍓 </ToggleGroup.Button> <ToggleGroup.Button value="banana" className="px-2"> Banana 🍌 </ToggleGroup.Button> <ToggleGroup.Button value="apple" className="px-2"> Apple 🍏 </ToggleGroup.Button> </ToggleGroup.Root> <button className="text-white bg-[rgb(171,135,255)] px-2 py-1 rounded-md">focusable element</button> </div> ) }
Despite being able to move the selection with the arrow keys, our toggle group isn't behaving as one unit yet.
If Strawberry is focused — and I hit tab
— the focus should move through the other buttons and to the next interactive element.
A roving tabindex
will help us accomplish that behavior.
Roving tabindex
concepts
Before we jump into implementation let's cover some basics.
What is a tabindex
?
- A
tabindex
is a HTML attribute that allows you to control whether something is focusable or not. - A
tabindex
of0
means that this element should appear in the normal tab order. - A
tabindex
of-1
means that this element shouldn't be in the tab order. - A
tabindex
of greater than0
allows you to manually set the tab order. (this is considered bad practice)
What is a roving tabindex
?
A roving tabindex
is a technique for controlling focus.
In a native radio group, your selection and focus are saved as you tab to and from the component.
With keyboard shortcuts alone we still don't get this type of experience in our toggle group.
When Apple 🍏 is selected, shift+tab
moves back to Banana 🍌 — the previous element in the tab order.
We need a solution that takes elements out of the tab order so that no extra tabbing is needed.
We need a solution that toggles tabindex
from -1
to 0
and back again based on whether the element is currently focused.
This kind of focus control is called a roving tabindex
.
Roving tabindex
implementation
I'll be implementing this roving tabindex
in three stages
- Tracking and toggling
tabindex
- Handling
tab
when initialvalue
isnull
- Handling
shift+tab
whenRoot
is focusable
Tracking and toggling tabindex
Let's create some state for which value is focused in our Root
component and pass it via context.
I could pass along the setter from useState
, but I am wrapping it to simplify the type of setFocusedValue
in my context.
The fact that I'm using useState
is an implementation detail that I don't want as part of the contract between the Root
and my Button
s.
Back in our Button
:
-
We create an
onFocus
to update thefocusedValue
-
We toggle
tabindex
based on whether thisButton
's value is currently focused -
Finally, since Safari doesn't trigger focus
onClick
we can force this interaction by.focus()
ing thecurrentTarget
.
Handling tab
when initial value
is null
Our implementation has an edge case. If the initial value of our toggle group is null
then no Button
s are focusable.
According to our spec, when the value
is null
and our component receives focus the first element should be focused.
Let's make our Root
focusable (tabindex={0}
), and its onFocus
can pass focus to the first Button
.
-
First we check that the focus is coming from the wrapper element — not one of its children.
This means that we don't run this logic on
focus
events that bubble up from ourButton
. We could alsoe.stopPropagation
in ourButton
but that is much more intrusive than just checking the source where we need it. -
We then reuse the
getOrderedItems
to easily find the first element in the list -
If the toggle group has value set - focus the related element
-
Otherwise focus the first element
Handling shift+tab
when Root
is focusable
Unfortunately, making the Root
focusable breaks shift+tab
.
That's because our Root
element receives focus and places it back on the child.
We can fix this by toggling the tabindex
of our Root
between the onKeyDown
of our Button
and the onBlur
of our Root
-
In the
Root
we can create some state for representing when the user pressesshift+tab
-
This state can then toggle the
Root
out of the tab order -
In our
Button
we can then set this value in theonKeyDown
ofshift+tab
-
This toggles our
tabIndex
value to-1
in theRoot
, and our focus leaves theToggleGroup
all together -
The
onBlur
of ourButton
occurs, bubbles up to theRoot
, and there we can reset ourRoot
'stabIndex
state
With that — we have a roving tabindex
🛻💨
import { useState } from "react"; import { ToggleGroup } from "./toggle-group" export default function App() { const [favoriteFruit, changeFavoriteFruit] = useState<string | null>("banana") return ( <div className="flex flex-col space-y-10 h-screen justify-center items-center"> <button className="text-white bg-[rgb(171,135,255)] px-2 py-1 rounded-md">focusable element</button> <ToggleGroup.Root value={favoriteFruit} onChange={changeFavoriteFruit} aria-label="What is your favorite fruit?" > <ToggleGroup.Button value="strawberry" className="px-2"> Strawberry 🍓 </ToggleGroup.Button> <ToggleGroup.Button value="banana" className="px-2"> Banana 🍌 </ToggleGroup.Button> <ToggleGroup.Button value="apple" className="px-2"> Apple 🍏 </ToggleGroup.Button> </ToggleGroup.Root> <button className="text-white bg-[rgb(171,135,255)] px-2 py-1 rounded-md">focusable element</button> </div> ) }
Aria attributes
The pattern for this component is already well defined. I recommend reading it yourself if you are implementing this component, but in the following two sections, I'll detail the changes required to make our component accessible.
Root
The Root
needs a role="radiogroup"
.
And we also need a way to enforce either a aria-label
or aria-labelledby
.
- I created a
Union (|)
type of two different methods of labeling aradiogroup
- I then created an
Intersection (&)
type of our label props with our existing props
This will alert the developer that they haven't specified an aria-label
or an aria-labeledby
.
Button
For the Button
, we can add the correct role="radio"
and an aria-checked
with the selectedValue
from context.
These properties provide context for all users to navigate our component. Here is one last look:
import { useState } from "react"; import { ToggleGroup } from "./toggle-group" export default function App() { const [favoriteFruit, changeFavoriteFruit] = useState<string | null>("banana") return ( <div className="flex h-screen justify-center items-center"> <ToggleGroup.Root value={favoriteFruit} onChange={changeFavoriteFruit} aria-label="What is your favorite fruit?" > <ToggleGroup.Button value="strawberry" className="px-2"> Strawberry 🍓 </ToggleGroup.Button> <ToggleGroup.Button value="banana" className="px-2"> Banana 🍌 </ToggleGroup.Button> <ToggleGroup.Button value="apple" className="px-2"> Apple 🍏 </ToggleGroup.Button> </ToggleGroup.Root> </div> ) }
That's a wrap on this component. But there is a bunch more to come ⚡
I still have a lot to learn about components and my plan for this year is to learn and document as much about React components as I can. The "hub" for that work can be found here and more examples of this component can be found here.
Links
- Component source
- More details on managing focus in composite widgets
- I got a ton of inspo for my roving
tabindex
from the Radix source. Shout out to their team.