Skip to main content

WCAG ARIA content navigation tabs which doesn't rely on libraries or frameworks.

Screen-reader friendly navigation tabs

A vanilla JavaScript version of Heydon Pickerings Simple ARIA tab interface with some slight modifications.

A Codepen demo is available. As used on the Tesco Easter 2016 hub (Opening hours results).

Navigation tabs example

For demo purposes a white focus outline is applied to clearly indicate keyboard actions. Amend to taste.

Section heading A

Section content A here Example focusable link A

Section heading B

Section content B here without a focusable link

Section heading C

Section content C here Example focusable link C

Use the tab key to jump link to visible link, and the arrow keys to navigate across the tabs.

Optionally allows mouse hover to activate the tab via the class "tl-hoverable" applied to the ul.

On-off states are controlled via CSS using ARIA properties.

Marking up the tabs

The tabs are an unordered list of links with a class of "tl_links" ("tl" represents "tablist"). Nothing special with an "tl_tab-on" class for initial state. The "tl-hoverable" class is optional and experimental.

Language HTML
<ul class="tl_list tl-hoverable">
  <li><a href="#A">Section A</a></li>
  <li><a href="#B" class="tl_tab-on">Section B</a></li>
  <li><a href="#C">Section C</a></li>
</ul>

Sections of content, one per tab. Only the ID is important as it ties the tab link to the content section.

Language HTML
<section id=A class=tl_section>
  <h3>Heading A</h3>
  <p>Content A</p>
</section>

<section id=B class=tl_section>
  <h3>Heading B</h3>
  <p>Content B</p>
</section>

<section id=C class=tl_section>
  <h3>Heading C</h3>
  <p>Content C</p>
</section>

Styling the tabs

Only one line of the CSS is required. The rest is just layout enhancement.

Uses flexbox to layout the tabs side-by-side.

Language CSS
.tl_list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: left;
}

.tl_list li {
  margin: 0;
  padding: 0;
  width: 33.3333%;
  text-align: center;
}

.tl_list a {
  padding: 1rem 0.5rem;
  display: block;
  text-decoration: none;
}

The hidden sections:

Language CSS
.tl_section {
  padding: 1rem;
  min-height: 3rem;
}

/* Lose any dangling margins from the content */
.tl_section > :last-child {
  margin-bottom: 0;
}

/* Colour the selected tab and the visible section the same */
[role="tablist"] [aria-selected="true"],
[role="tabpanel"] {
  color: #007471;
  background-color: #D9E4F6;
}

Remove hidden sections from the keychain.
This is the only CSS rule actually required.

Language CSS
[role="tabpanel"][aria-hidden="true"] {
  display: none;
}

Scripting

JavaScript is required for the tabs to function as expected so therefore the ARIA roles and regions are added by the JavaScript.

Language JavaScript
var accessibleTabs6 = (function () {
  "use strict";
  var d = document;
  var tabListClass;
  var tabs;
  var onClass;
  var useHover;

Tab switching is done via the ARIA controls.

Language JavaScript
  var _setTab = function (tab, switchOn) {
    d.getElementById(tab.panelId).setAttribute("aria-hidden", !switchOn);
    tab.setAttribute("aria-selected", switchOn);
    tab.setAttribute("tabindex", switchOn ? "0" : "-1");
  };
Language JavaScript
  var _setTabsOff = function () {
    var i = tabs.length;
    while (i--) {
      _setTab(tabs[i], false);
    }
  };
Language JavaScript
  var _activateTab = function (e) {
    var tab = e.target;
    e.preventDefault();
    _setTabsOff();
    _setTab(tab, true);
    d.getElementById(tab.panelId).children[0].focus();
  };

Allow the arrow left & right keys to select a different tab.

Language JavaScript
  var _keypressed = function (e) {

    var tab = e.target;
    var newNo = -1;
    var maxNo = tabs.length - 1;

    if (e.keyCode === 37) { // left arrow
      newNo = (tab.no === 0) ? maxNo : tab.no - 1;
    }
    if (e.keyCode === 39) { // right arrow
      newNo = (tab.no === maxNo) ? 0 : tab.no + 1;
    }
    if (newNo > -1) {
      _setTabsOff();
      _setTab(tabs[newNo], true);
      tabs[newNo].focus();
    }
  };

Event listeners for click and keypress. Optionally adding hover and focus.

Language JavaScript
  var _events = function (tab) {
    tab.addEventListener("click", _activateTab, false);
    tab.addEventListener("keydown", _keypressed, false);
    if (useHover) {
      tab.addEventListener("mouseover", _activateTab, false);
    }
  };

Initialise the tab and panel ARIA attributes.

Language JavaScript
  var _initialiseAriaAttributes = function (tab) {

    var tabPanel = d.getElementById(tab.panelId);

    tab.parentNode.setAttribute("role", "presentation");
    tab.setAttribute("role", "tab");
    tab.setAttribute("aria-controls", tab.panelId);

    tabPanel.setAttribute("role", "tabpanel");
    tabPanel.setAttribute("aria-labelledby", tab.id);

    // Make first section object keyboard focusable (
    // preferably a heading
    tabPanel.children[0].setAttribute("tabindex", "0");
  };

Read the class configuration and apply to closure variables.

Language JavaScript
  var _setUpConfig = function (cfg) {
    tabListClass = cfg.tabListClass || "tl_list";
    onClass = cfg.onClass || "tl_tab-on";
    useHover = tabList.classList.contains(cfg.hoverableClass || "tl-hoverable");
  };

Initialise tab values.

Language JavaScript
  var _setUpTab = function (tab, panelId, count) {
    tab.no = count;
    tab.id = "tab-" + panelId;
    tab.panelId = panelId;
    _initialiseAriaAttributes(tab);
    _events(tab);
  };

Initialise accessible tabs.

Language JavaScript
  var _initialiseTabList = function (tabList) {

    var defaultTab = 0;
    var panelId;
    var tabPanel;
    var i;

    if (tabList) {

      tabList.setAttribute("role", "tablist");
      tabs = tabList.getElementsByTagName("a");
      i = tabs.length;

      while (i--) {

        panelId = tabs[i].href.slice(tabs[i].href.lastIndexOf("#") + 1);
        tabPanel = d.getElementById(panelId);

        if (tabPanel) {
          _setUpTab(tabs[i], panelId, i);
          if (tabs[i].classList.contains(onClass)) {
            defaultTab = i;

            // onClass only used to declare intial state, so remove from DOM
            tabs[i].classList.remove(onClass);
            d.getElementById(panelId).classList.remove(onClass);
          }
        }
      }
      _setTabsOff();
      _setTab(tabs[defaultTab], true);
    }
  };

Check browser support level.

Language JavaScript
  var _isMustardCut = function () {
    return (
      (typeof d.querySelectorAll === "function") &&
      d.addEventListener &&
      !!d.documentElement.classList
    );
  };

Find and run through each tablist.

Language JavaScript
  var init = function (cfg) {
    var tabLists;
    var i;

    if (_isMustardCut()) {
      _setUpConfig(cfg);
      tabLists = d.getElementsByClassName(tabListClass);
      i = tabLists.length;
      while (i--) {
        _initialiseTabList(tabLists[i]);
      }
    }
  };

Expose the initialise function to the outside world.

Language JavaScript
  return {
    init: init
  };

}());

Run with optional class names over rides.

Language JavaScript
accessibleTabs6.init({
  tabListClass : "tl_list",        // default, may omit
  onClass : "tl_tab-on",           // default, may omit
  hoverableClass : "tl-hoverable"  // default, may omit
});

Socialise: