Modern and accessible tabbed navigation which spark joy in the user and in the dev.
For tabs to be good it needs to achieve the following at least:
- Accessible with screen readers at al.
- Navigable from the keyboard.
- Works when JavaScript is disabled.
- Looks good.
Tabs without logic
It’s good to start from the lowest level; try and envision what the tabs look like when JavaScript is disabled, the text in the tabs should still be viewable (see the "No JavaScript" section for more)
Good naming convention is to use the role names for the tab components (more
on the roles below):
<TabList>: theulelement that wraps the tabs.<Tab>: theaelements in the tablist.<TabPanel>: this is where the tab’s content lives.
It should look something like the following demo. The tabpanels have IDs and the
tabs are anchor links to the tabpanels <a href='#css-tab'>css</a>:
<Tabs
// A list of tabs, must be unique array
tabs={['result', 'html', 'css', 'js']}
// This gets appended to every `id` in the tab system, this way there can be
// multiple tab sets on the page and not cause `id` clashes
id='1'
/>See the full component
This is styled with styled-components but you do you.
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
const TabList = styled.ul({
display: 'flex',
gap: '1px',
listStyleType: 'none',
margin: '0 0 1px',
})
const TabItem = styled.li({
flex: '1 0 auto',
margin: 0,
})
// 1. Stretch the link to fill its parent
const Tab = styled.a({
backgroundColor: 'var(--gray-light)',
display: 'flex',
flexGrow: 1 /* 1 */,
padding: '10px 15px',
textTransform: 'uppercase',
})
const TabPanel = styled.div({
backgroundColor: 'var(--gray-light)',
marginBottom: '1px',
minHeight: '100px',
padding: '15px',
})
const Tabs = ({ tabs = [], id }) => {
const getId = (...rest) => [...rest, id].join('-')
return (
<div>
<TabList>
{tabs.map(tab => (
<TabItem key={tab}>
<Tab href={'#' + getId(tab, 'tabpanel')}>{tab}</Tab>
</TabItem>
))}
</TabList>
{tabs.map(tab => (
<TabPanel id={getId(tab, 'tabpanel')} key={tab}>
{tab} tab’s content
</TabPanel>
))}
</div>
)
}
Tabs.propTypes = {
tabs: PropTypes.array.isRequired,
id: PropTypes.string.isRequired,
}
export default TabsNext we need to stack the tabs and show the active tab at the top.
Tabs with logic
We’ll add activeTab state and a click handler to the link elements. Now it
already works and looks like tabs. But it’s not done yet, it needs to be
operable from the keyboard and it needs other accessibility features. See below.
See the full component sofar
See the highlighted lines for changes.
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
const TabNav = styled.ul({
display: 'flex',
gap: '1px',
listStyleType: 'none',
margin: '0 0 1px'
})
const TabItem = styled.li(props => ({
// highlight-range{1-3}
backgroundColor: props.isActive
? 'var(--gray-light)'
: 'var(--gray-lightest)',
flex: '1 0 auto',
margin: 0,
textTransform: 'uppercase'
}))
// 1. Stretch the link to fill its parent
const TabLink = styled.a({
display: 'flex',
flexGrow: 1 /* 1 */,
padding: '10px 15px'
})
const Tab = styled.div(props => ({
backgroundColor: 'var(--gray-light)',
// highlight-range{1}
display: !props.isActive ? 'none' : undefined
marginBottom: '1px',
minHeight: '400px',
padding: '15px',
}))
const Tabs = ({ tabs = [], id }) => {
// highlight-range{1}
const [activeTab, setActiveTab] = React.useState(tabs[0])
// highlight-range{1-4}
const handleClick = tab => event => {
event.preventDefault()
setActiveTab(tab)
}
const getId = (...rest) => [...rest, id].join('-')
return (
<div>
<TabNav>
{tabs.map(tab => (
<TabItem key={tab}>
<TabLink
href={'#' + getId(tab, 'tabpanel')}
// highlight-range{1-3}
id={`#${tab}`}
isActive={activeTab === tab}
onClick={handleClick(tab)}
>
{tab}
</TabLink>
</TabItem>
))}
</TabNav>
{tabs.map(tab => (
// highlight-range{1}
<Tab id={getId(tab, 'tabpanel')} key={tab} isActive={activeTab === tab}>
{tab} tab’s content
</Tab>
))}
</div>
)
}
Tabs.propTypes = {
tabs: PropTypes.array.isRequired,
id: PropTypes.string.isRequired
}
export default TabsMake it accessible
Unfortunately tabs aren’t that accessible out of the box, so we need to add some
aria, role, and id attributes here and there.
Role attributes
There are three different roles in the tab system: tablist, tab, and
tabpanel, it’s easy to figure out which role goes where since we named the
components after the role names.
- role="tablist"
A list of tab elements, which are references to tabpanel elements.
In this case it’s theulthat wraps the tabs navigation.- role="tab"
The ARIA tab role indicates an interactive element inside a tablist that, when activated, displays its associated tabpanel.
In this case the
aelements in the tablist.- role="tabpanel"
A container for the resources associated with a tab, where each tab is contained in a tablist.
The panel where the tab’s content lives.
Aria attributes
There are the following ARIA attributes:
- aria-selected
Indicates the current “selected” state of various widgets.
This goes into theTabcomponent.- aria-labelledby
The
aria-labelledbyattribute establishes relationships between objects and their label(s). The value should be theidof the element which the relationship is wanted to be formed.In this case the relationship is between the
Taband theTabPanel. Thearia-labelledbyin theTabPanelshould have the ID of its corresponding tab.
Demo
The demo looks exactly the same as the above one, but if you inspect it you’ll see the role and aria attributes.
See the full component sofar
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
const TabNav = styled.ul({
display: 'flex',
gap: '1px',
listStyleType: 'none',
margin: '0 0 1px',
})
const TabItem = styled.li({
flex: '1 0 auto',
margin: 0,
})
// 1. Stretch the link to fill its parent
const TabLink = styled.a(props => ({
backgroundColor: props.isActive
? 'var(--gray-light)'
: 'var(--gray-lightest)',
display: 'flex',
flexGrow: 1 /* 1 */,
padding: '10px 15px',
textTransform: 'uppercase',
}))
const Tab = styled.div(props => ({
backgroundColor: 'var(--gray-light)',
marginBottom: '1px',
minHeight: '400px',
padding: '15px',
display: !props.isActive ? 'none' : undefined,
}))
const Tabs = ({ tabs = [], id }) => {
const [activeTab, setActiveTab] = React.useState(tabs[0])
const handleClick = tab => event => {
event.preventDefault()
setActiveTab(tab)
}
const getId = (...rest) => [...rest, id].join('-')
return (
<div>
// highlight-next-line
<TabNav role='tablist'>
{tabs.map(tab => {
const isActive = activeTab === tab
return (
<TabItem key={tab}>
<TabLink
// highlight-next-line
aria-selected={isActive}
href={'#' + getId(tab, 'tabpanel')}
id={getId(tab)}
isActive={isActive}
// highlight-next-line
role='tab'
onClick={handleClick}
>
{tab}
</TabLink>
</TabItem>
)
})}
</TabNav>
{tabs.map(tab => (
<Tab
// highlight-next-line
aria-labelledby={getId(tab)}
id={getId(tab, 'tabpanel')}
isActive={activeTab === tab}
key={tab}
// highlight-next-line
role='tabpanel'
>
{tab} tab’s content
</Tab>
))}
</div>
)
}
Tabs.propTypes = {
tabs: PropTypes.array.isRequired,
id: PropTypes.string.isRequired,
}
export default TabsAdd keyboard support
Keyboard shortcuts are an important part of accessibility, so it shouldn’t be skipped.
Tabs have a very specific set of requirements for keyboard, in this case we’re building tabbed system where the selection automatically follows focus. Meaning, when focus moves to the tab its corresponding tabpanel is displayed immediately. The other option would be to require user to press enter on each tab to display iy. According to this article in w3.org you should favor the selection-follows-focus method if you have the tabpanel’s content already in the DOM, but if you need to wait for it, in form of a request or page load on tab activation, then it should’t be automatic.
| Key name | What it does |
|---|---|
| ⇥ Tab |
|
| ▶ Right |
|
| ◀ Left |
|
| ⤒ Home | Moves focus to the first tab and activates it. |
| ⤓ End | Moves focus to the last tab and activates it. |
If a Tab key (rather than the arrow) moves focus to the next tab, then users can’t ever move focus into the tabpanel. The arrows need to have their own event listener. Let’s construct that step-by-step.
Listen to keyboard input
If you just want to see the full component, skip to the demo section below.
Listen to the keyboard on the <Tab> component, you can use onKeyDown or
onKeyUp:
<Tab
aria-selected={isActive}
href={'#' + getId(tab, 'tabpanel')}
id={getId(tab)}
isActive={isActive}
onClick={handleClick(tab)}
// highlight-next-line
onKeyDown={handleKeyboard(tab)}
role="tab"
>Make the keyboard handler
Then in the handleKeyboard function, store the first, last, next, and prev tab
names into variables:
// This doesn’t need to be curried, it’s just easier to pass the event in
const handleKeyboard = tab => event => {
const tabCount = tabs.length - 1
const currentTabIndex = tabs.indexOf(tab)
const firstTab = tabs[0]
const lastTab = tabs[tabCount]
const nextTab = tabs[currentTabIndex + 1]
const prevTab = tabs[currentTabIndex - 1]
// More stuff here, read on...
}Then listen to the keys and actually focus the needed tab with the focusTab
(see below) helper:
// This doesn’t need to be curried, it’s just easier to pass the event in
const handleKeyboard = tab => event => {
const tabCount = tabs.length - 1
const currentTabIndex = tabs.indexOf(tab)
const firstTab = tabs[0]
const lastTab = tabs[tabCount]
const nextTab = tabs[currentTabIndex + 1]
const prevTab = tabs[currentTabIndex - 1]
if (event.key === 'ArrowRight') {
// The focusTab helper does all the heavy lifting
// highlight-next-line
if (tabCount > currentTabIndex) return focusTab(nextTab)
return focusTab(firstTab)
}
if (event.key === 'ArrowLeft') {
if (currentTabIndex > 0) return focusTab(prevTab)
return focusTab(lastTab)
}
if (event.key === 'Home') {
event.preventDefault()
return focusTab(firstTab)
}
if (event.key === 'End') {
event.preventDefault()
return focusTab(lastTab)
}
}Focus the right tab
In order to focus elements, we need to have a ref to every tab, the ref is
set in the <Tab> component and is stored in the tabRefs object.
Initiate an empty object somewhere in the component:
const tabRefs = {}Then, the ref needs to be set in the <Tab> component, just one line:
<Tab
aria-selected={isActive}
href={'#' + getId(tab, 'tabpanel')}
id={getId(tab)}
isActive={isActive}
onClick={handleClick(tab)}
onKeyDown={handleKeyboard(tab)}
// highlight-next-line
ref={ref => (tabRefs[tab] = ref)}
role='tab'
>
{tab}
</Tab>Now make the focusTab helper where you set the active tab state and move focus
to the wanted tab:
const focusTab = tab => {
setActiveTab(tab)
tabRefs[tab].focus()
}Make the interactive elements in tabpanels focusable
At this point, if you’d press Tab (when a tab is focused), the focus moves to the next tab, and this is wrong, because then keyboard users can’t access content in the tabpanel. Focus should move to the tabpanel and then to a possible focusable element in tabpanel.
This can be done by being smart with tabIndex:
<Tab
aria-selected={isActive}
href={'#' + getId(tab, 'tabpanel')}
id={getId(tab)}
isActive={isActive}
onClick={handleClick(tab)}
onKeyDown={handleKeyboard(tab)}
// highlight-next-line
tabIndex={isActive ? 0 : -1}
ref={ref => (tabRefs[tab] = ref)}
role='tab'
>
{tab}
</Tab>And in the TabPanel:
<TabPanel
aria-labelledby={getId(tab)}
id={getId(tab, 'tabpanel')}
isActive={activeTab === tab}
key={tab}
// highlight-next-line
tabIndex={0}
role='tabpanel'
>
Content...
</TabPanel>The element can’t be focused if tabIndex is set to -1.
Demo
See the final full component
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
const Wrap = styled.div({
marginBottom: '20px',
})
const TabList = styled.ul({
display: 'flex',
gap: '1px',
listStyleType: 'none',
margin: '0 0 1px',
})
const TabItem = styled.li({
flex: '1 0 auto',
margin: 0,
})
// 1. Stretch the link to fill its parent
const Tab = styled.a(props => ({
backgroundColor: props.isActive
? 'var(--gray-light)'
: 'var(--gray-lightest)',
display: 'flex',
flexGrow: 1 /* 1 */,
padding: '10px 15px',
textTransform: 'uppercase',
}))
const TabPanel = styled.div(props => ({
backgroundColor: 'var(--gray-light)',
marginBottom: '1px',
minHeight: '400px',
padding: '15px',
display: !props.isActive ? 'none' : undefined,
}))
const Tabs = ({ tabs = [], id }) => {
const [activeTab, setActiveTab] = React.useState(tabs[0])
// Store the tab element references here
const tabRefs = React.useRef({})
const handleClick = tab => event => {
event.preventDefault()
setActiveTab(tab)
}
const getId = (...rest) => [...rest, id].join('-')
const focusTab = tab => {
setActiveTab(tab)
// Get the reference from the `tabRefs` and actual move focus to the tab
tabRefs.current[tab].focus()
}
const handleKeyboard = tab => event => {
const tabCount = tabs.length - 1
const currentTabIndex = tabs.indexOf(tab)
const firstTab = tabs[0]
const lastTab = tabs[tabCount]
const nextTab = tabs[currentTabIndex + 1]
const prevTab = tabs[currentTabIndex - 1]
if (event.key === 'ArrowRight') {
if (tabCount > currentTabIndex) return focusTab(nextTab)
return focusTab(firstTab)
}
if (event.key === 'ArrowLeft') {
if (currentTabIndex > 0) return focusTab(prevTab)
return focusTab(lastTab)
}
if (event.key === 'Home') {
event.preventDefault()
return focusTab(firstTab)
}
if (event.key === 'End') {
event.preventDefault()
return focusTab(lastTab)
}
}
return (
<Wrap>
<TabList role='tablist'>
{tabs.map(tab => {
const isActive = activeTab === tab
return (
<TabItem key={tab}>
<Tab
aria-selected={isActive}
href={'#' + getId(tab, 'tabpanel')}
id={getId(tab)}
isActive={isActive}
onClick={handleClick(tab)}
onKeyDown={handleKeyboard(tab)}
tabIndex={isActive ? 0 : -1}
ref={ref => (tabRefs.current[tab] = ref)}
role='tab'
>
{tab}
</Tab>
</TabItem>
)
})}
</TabList>
{tabs.map(tab => (
<TabPanel
aria-labelledby={getId(tab)}
id={getId(tab, 'tabpanel')}
isActive={activeTab === tab}
key={tab}
tabIndex={0}
role='tabpanel'
>
Content of <b>{tab}</b> with a <a href='/tabs'>focusable</a> element.
</TabPanel>
))}
</Wrap>
)
}
Tabs.propTypes = {
tabs: PropTypes.array.isRequired,
id: PropTypes.string.isRequired,
}
export default TabsNo JavaScript usage
If no JavaScript is available then the isActive boolean needs to be always
false:
const isActive = hasJavaScript && activeTab === tabHow to check if JavaScript is turned off? It depends on your system
completely, but for example Gatsby has a plugin called
gatsby-plugin-js-fallback,
see the
source code
of that plugin. It sets a values in the JsEnabledContext inside useEffect.
Here’s also an SO thread on the topic of detecting JavaScript.
Possible improvements...
Pass it a prop to configure the initially active tab:
const Tabs = ({ tabs = [], id, initialTab = 0 }) => {
const [activeTab, setActiveTab] = React.useState(initialTab)
// . . .
}
Tabs.propTypes = {
tabs: PropTypes.array.isRequired,
id: PropTypes.string.isRequired,
initialTab: PropTypes.number,
}Now the component basically expects to load its own data, but it could also take content:
Tabs.propTypes = {
tabs: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
})
).isRequired,
id: PropTypes.string.isRequired,
initialTab: PropTypes.number,
}How to make this more reusable and themable... this could be its own post.
References
Thanks to these sites for in-depth info on tabs:
- https://websemantics.uk/articles/accessible-tab-navigation/
- https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
Conclusions
Some things in web are accessible right out of the box, like, I don't know, iframes for example. But some things are not, and tabs belong to the latter category. Half of the work goes to making it accessible, but that’s all good. As someone said about accessibility:
You’re not doing someone a favor, you’re just doing your job.
Hope this was helpful. Thanks for reading!