# Accessible tabbed navigation with React

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](#no-javascript-usage) for more)

Good naming convention is to use the `role` names for the tab components (more
on the [roles below](#role-attributes)):

- `<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>`:

```jsx
<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'
/>
```

[Interactive example](/tabs)

See the full component

This is styled with `styled-components` but you do you.

```js
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.

[Interactive example](/tabs)

See the full component sofar

See the highlighted lines for changes.

```js
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 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 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 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 the 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.

[Interactive example](/tabs)

See the full component sofar

```jsx
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 Tabs
```

## Add 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](https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus)
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&nbsp;name
      What 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.

▶&nbsp;Right

- Moves focus to the next tab in the list.

- If focus is in the last tab, then cycle back to the first tab.

◀&nbsp;Left

- Moves focus to the previous tab in the list.

- If focus is in the first tab, then cycle back to the last tab.

⤒&nbsp;Home

Moves focus to the first tab and activates it.

⤓&nbsp;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.

### Listen to keyboard input

If you just want to see the full component,
[skip to the demo section below](#demo-1).

Listen to the keyboard on the `<Tab>` component, you can use `onKeyDown` or
`onKeyUp`:

```js
<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:

```js
// 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](#focus-the-right-tab)) helper:

```js
// 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:

```js
const tabRefs = {}
```

Then, **the ref needs to be set in the `<Tab>` component**, just one line:

```jsx
<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>
```

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:

```js
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`:

```js
<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`:

```js
<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

[Interactive example](/tabs)

See the **final** full component

```jsx
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:

```js
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`](https://www.gatsbyjs.com/plugins/gatsby-plugin-js-fallback/),
see the
[source code](https://github.com/malcolm-kee/gatsby-plugin-js-fallback/blob/master/index.js)
of that plugin. It sets a values in the `JsEnabledContext` inside `useEffect`.

Here’s also an SO thread on the topic of
[detecting JavaScript](https://stackoverflow.com/questions/121203/how-to-detect-if-javascript-is-disabled).

## Possible improvements...

Pass it a prop to configure the initially active tab:

```js
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:

```js
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!
