clubmate.fi

A good[ish] website

Web development blog, loads of UI and JavaScript topics

Accessible tabbed navigation with React

Filed under: UI components— Tagged with: React, keyboard, accessibility

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 disables.
  • 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>: 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"
/>
result tab’s content
html tab’s content
css tab’s content
js tab’s content
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 Tabs

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

result tab’s content
html tab’s content
css tab’s content
js tab’s content
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 => ({
  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

Make 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’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.

role="tablist"
A list of tab elements, which are references to tabpanel elements. In this case it’s the ul that 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 a elements 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 following aria attributes:

aria-selected
Indicates the current “selected” state of various widgets.This goes into the Tab component.
aria-labelledby

The aria-labelledby attribute establishes relationships between objects and their label(s). The value should be the 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.

Demo

The demo looks exactly the same as the above one, but if you inspect it you’ll see the role and aria attributes.

result tab’s content
html tab’s content
css tab’s content
js tab’s content
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>
      <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

Add keyboard support

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 nameWhat it does
⇥ Tab
  • Should activate the first tab when focus moves to the tab list.
  • When focus is in one of the tabs, then Tab should move focus into a focusable element in the tab panel.
▶ Right
  • Moves focus to the next tab in the list.
  • If focus is in the last tab, then cycle back to the first tab.
◀ Left
  • Moves focus to the previous tab in the list.
  • If focus is in the first tab, then cycle back to the last tab.
⤒ HomeMoves focus to the first tab and activates it.
⤓ EndMoves 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.

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)}
  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
    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)}
  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()
}

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)}
  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.

Demo

Content of result with a focusable element.
Content of html with a focusable element.
Content of css with a focusable element.
Content of js with a focusable element.
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 Tabs

No JavaScript usage

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.

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:

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!

Comments would go here, but the commenting system isn’t ready yet, sorry. Tweet me @intterne if you want to make a correction etc.

  • © 2022 Antti Hiljá
  • About
  • Follow my new Twitter account → @intterne
  • All rights reserved yadda yadda.
  • I can put just about anything here, no one reads the footer anyways.
  • I love u!