A good[ish] website
Web development blog, loads of UI and JavaScript topics
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:
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>
: the ul
element that wraps the tabs.<Tab>
: the a
elements 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"
/>
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 Tabs
Next we need to stack the tabs and show the active tab at the top.
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 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 => ({
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)',
display: !props.isActive ? 'none' : undefined marginBottom: '1px',
minHeight: '400px',
padding: '15px',
}))
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>
<TabNav>
{tabs.map(tab => (
<TabItem key={tab}>
<TabLink
href={'#' + getId(tab, 'tabpanel')}
id={`#${tab}`} isActive={activeTab === tab} onClick={handleClick(tab)} >
{tab}
</TabLink>
</TabItem>
))}
</TabNav>
{tabs.map(tab => (
<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 Tabs
Unfortunately tabs aren’t that accessible out of the box, so we need to add some aria
, role
, and id
attributes here and there.
There’s 3 different roles to 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.
A list of tab elements, which are references to tabpanel elements.In this case it’s the
ul
that wraps the tabs navigation.The ARIA tab role indicates an interactive element inside a tablist that, when activated, displays its associated tabpanel.In this case the
a
elements in the tablist.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.
There are following aria attributes:
Indicates the current “selected” state of various widgets.This goes into the
Tab
component.The
The value should be the aria-labelledby
attribute establishes relationships between objects and their label(s).id
of the element which the relationship is wanted to be formed.
In this case the relationship is between the Tab
and the TabPanel
. The aria-labelledby
in the TabPanel
should have the ID of its corresponding tab.
The demo looks exactly the same as the above one, but if you inspect it you’ll see the role and aria attributes.
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>
<TabNav role="tablist"> {tabs.map(tab => {
const isActive = activeTab === tab
return (
<TabItem key={tab}>
<TabLink
aria-selected={isActive} href={'#' + getId(tab, 'tabpanel')}
id={getId(tab)}
isActive={isActive}
role="tab" onClick={handleClick}
>
{tab}
</TabLink>
</TabItem>
)
})}
</TabNav>
{tabs.map(tab => (
<Tab
aria-labelledby={getId(tab)} id={getId(tab, 'tabpanel')}
isActive={activeTab === tab}
key={tab}
role="tabpanel" >
{tab} tab’s content
</Tab>
))}
</div>
)
}
Tabs.propTypes = {
tabs: PropTypes.array.isRequired,
id: PropTypes.string.isRequired
}
export default Tabs
Keyboard shortcuts are a really important part of accessibility so it should’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. |
On a MacBook keyboard press fn + ▶ for Home, and fn + ◀ for End
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.
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)}
onKeyDown={handleKeyboard(tab)} role="tab"
>
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
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)
}
}
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)}
ref={ref => (tabRefs[tab] = ref)} role="tab"
>
{tab}
</Tab>
In some CSS-JS libraries, like Fela, you might need to use refCallback
instead of ref
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()
}
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)}
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}
tabIndex={0} role="tabpanel"
>
Content...
</TabPanel>
The element can’t be focused if tabIndex
is set to -1
.
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 Tabs
If no JavaScript is available then the isActive
boolean needs to be always false:
const isActive = hasJavaScript && activeTab === tab
How 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.
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.
Thanks to these sites for in-depth info on tabs:
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!
Comments would go here, but the commenting system isn’t ready yet, sorry.