Sanely manage Tab accessibility in React with <Untabbable>
and the
useTabIndex
hook!
- Support for nested tabbable states – for nested accordions, menus, modals, etc.
- Performs no manual DOM manipulation, querying, or ref management – just pure declarative React.
- Less than 1 kB minified, no dependencies.
Install with your choice of package manager:
$ yarn add react-tabindex
$ npm install react-tabindex
The useTabIndex
hook returns a value to pass to the tabIndex
prop on
elements of your choosing. If wrapped in an active <Untabbable>
ancestor, that
tabIndex
value will automatically be set to -1
, making the elements
untabbable.
Start using useTabIndex
for any tabbable elements you render in your
components. Remember to get them all if you want correct behavior! Buttons,
links, inputs, and all the rest.
import { useTabIndex } from 'react-tabindex';
function ExampleButton() {
const tabIndex = useTabIndex();
return <button tabIndex={tabIndex}>Click here!</button>;
}
If you have a desired tabIndex
value (for example, from props) you can pass
that as an argument:
import { useTabIndex } from 'react-tabindex';
function ExampleButton({ children, tabIndex }) {
// Override the input `tabIndex` with the result of `useTabIndex`.
tabIndex = useTabIndex(tabIndex);
return <button tabIndex={tabIndex}>{children}</button>;
}
Now, when a section of your app becomes untabbable, wrap it in an <Untabbable>
and toggle the active
prop. A good example is carousel slides that are not
visible:
import { Untabbable, useTabIndex } from 'react-tabindex';
function Carousel({ activeIndex, items }) {
return (
<section className="carousel" aria-label="My Presentation">
<ul>
{items.map((item, index) => (
// Make all carousel items except the active one untabbable.
// NOTE: Instead of conditionally adding/removing the `<Untabbable>`
// wrapper, you should instead toggle its `active` prop (otherwise,
// React will remount the entire subtree each time, since the
// structure is changing).
<Untabbable active={index !== activeIndex} key={item.key}>
<li className="slide">{item}</li>
</Untabbable>
))}
</ul>
</section>
);
}
function IntroSlide() {
const tabIndex = useTabIndex();
return (
<section>
<h1>Prestige Worldwide</h1>
<a href="https://prestige.worldwide/" tabIndex={tabIndex}>
Visit our website!
</a>
<button tabIndex={tabIndex}>Next Slide</button>
</section>
);
}
For best results, make your component library (design system) primitives like
<Button>
, <Input>
, etc. use this so that it’s automatic.
Sometimes you have nested regions that need to become untabbable. A good example
would be a nested accordion/collapsible style menu. It is fine (and expected) to
nest <Untabbable>
elements in this scenario, and it will behave correctly out
of the box. That is to say, any ancestor Untabbable
being active will
override the active
state of any Untabbable
descendants.
In the following example, a nested collapsible menu uses <Untabbable>
when its
children are collapsed. Even if a submenu is in the expanded state
(<Untabbable active={false}>
), a parent menu being collapsed will cause that
parent’s Untabbable
to override the state of any Untabbable
descendants.
You may be wondering: in a real app, wouldn’t you use a property like hidden
or CSS like display: none
on collapsed elements, which naturally removes any
elements therein from the tab order? That may be the case – but you may also
want to animate the content that is being expanded or collapsed, and during
that time it will still be visible and tabbable – so it’s a good idea to use
Untabbable
anyway even if the final resting state of the collapsed content is
already removed from the tab order. Other times, like with a carousel, the
inactive items may never be set to display: none
in the first place, so
Untabbable
becomes essential.
function Collapsible({ id, label, children }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button aria-controls={id} aria-expanded={expanded}>
{label}
</button>
<Untabbable active={!expanded}>
<div id={id} role="region" style={{ height: expanded ? 'auto' : 0 }}>
{children}
</div>
</Untabbable>
</div>
);
}
function App() {
return (
<Collapsible label="Produce">
<Collapsible label="Fruits">
<Collapsible label="Apples">
<ul>
<li>Cosmic Crisp</li>
<li>Fuji</li>
<li>Pink Lady</li>
</ul>
</Collapsible>
</Collapsible>
<Collapsible label="Vegetables">
<Collapsible label="Onions">
<ul>
<li>Red Onions</li>
<li>Yellow Onions</li>
<li>White Onions</li>
</ul>
</Collapsible>
</Collapsible>
</Collapsible>
);
}
The Untabbable
component has a reset
prop that allows you to ignore the
state of any ancestors, causing it to no longer inherit their active
state.
This feature is primarily useful for modals, which would otherwise inherit the
app’s Untabbable
state, but instead intentionally “break out” of the
underlying content. Let’s take a look.
function App() {
const [isModalOpen, setModalOpen] = useState(false);
return (
<Untabbable isActive={isModalOpen}>
<div aria-hidden={isModalOpen}>
<Button onClick={() => setModalOpen(true)}>Open Modal</Button>
{isModalOpen ? (
<Modal>
<h2>It worked!</h2>
<p>Content in here is still tabbable.</p>
<Button>Like this</Button>
</Modal>
) : null}
</div>
</Untabbable>
);
}
function Button({ tabIndex, ...rest }) {
tabIndex = useTabIndex(tabIndex);
return <button tabIndex={tabIndex} {...rest} />;
}
function Modal({ onClose, children }) {
return ReactDOM.createPortal(
// Use `reset` so that modal content does not inherit the `Untabbable` state
// from the rest of the app, which is hidden when the modal is open.
<Untabbable reset>
<div role="dialog">
<Button onClick={onClose}>Close</Button>
{children}
</div>
</Untabbable>,
document.body
);
}
In this simple example, there is only one modal. But you can imagine
implementing a more advanced scenario with multiple stacked modals, which is
also possible and will use the same reset
feature. See the test suite for such
an example.
For this to work correctly on all elements in your app (and not leave any
tabbable when they should be untabbable), you must ensure that all focusable
elements use the useTabIndex
hook. This can be tedious and hard to remember,
which is why it’s a good idea to incorporate it into your component library
(design system) and make sure everyone uses it.
If you are rendering any static HTML (using dangerouslySetInnerHTML
, for
example with data from a CMS), unfortunately there will be no way to ensure that
content uses useTabIndex
, since it is not controlled by React.
If you have other content on the page outside of your React root, or rendered by
other non-React JavaScript widgets, there will likewise be no easy way to apply
useTabIndex
to that content. You will have to resort to manual DOM
manipulation side effects to handle such cases.
Uses tabbable under the hood to
automatically identify tabbable elements inside an Untabbable
region and
modify their tabindex
attribute using manual DOM manipulation. This has pros
and cons.
Pros
- It’s more automatic as the tabbable elements don’t have to individually use a
special hook to update their
tabIndex
prop. - It will also work with static HTML inside the
Untabbable
region that is not controlled by React (e.g.dangerouslySetInnerHTML
).
Cons
- It is usually not kosher for a React component to have its DOM modified from another React component.
- Can lead to inconsistent state. For example, let’s say an
Untabbable
region becomes active, and all its descendants have theirtabindex
attribute modified. Since those descendants may be dynamic React components, it’s possible for them to still be adding, removing, or modifying content after the DOM was already modified. For instance, let’s say one is loading data, then renders new elements when the data is available – that updated content will not have the correcttabindex
, since theUntabbable
already ran its DOM manipulation. It’s also possible that React will unknowingly undo thetabindex
modification performed by theUntabbable
ancestor during rerendering. The advantage of usingreact-tabindex
is that state is guaranteed to stay in sync and reactive (which is the whole idea behind React)!