Tabpanel

Tabpanel

Overview

A tab panel pattern consists of tabs and tab panels. The tabs are visible at all times, but only one tab panel is visible at a time. The tabs act like buttons. When the tab is activated, the corresponding panel is made visible, and all other panels are hidden. Usually the active tab is styled differently from the inactive tabs, to set it apart visually.

See also the Official W3C documentation about ARIA tabpanel patterns.



Turn on a screen reader to experience this example in action.

  • Perceivable
  • Operable
  • Understandable
  • Robust
Making content perceivable means making the output available to the user's senses, namely sight, sound, and touch (in the case of people who use Braille output devices). We won't worry about tasting or smelling web pages!
Making content operable means making the input mechanisms robust enough to accept a wide range of devices and methods, including keyboard, mouse, touch, gestures, single-switch devices, and so on.
Making content understandable means making the message and the interface easy to use and comprehend.
Making content robust means ensuring it works across a wide range of devices, with both forward and backward compatibility.

Initial HTML Markup

<div class="deque-tabpanel" data-deque-tabpanel-autoselect="true" data-deque-tabpanel-autoplay="false" id="pour">
  <ul role="tablist">
    <li role="tab" aria-controls="panel-perceivable">
      Perceivable
    </li>
    <li role="tab" aria-controls="panel-operable">
      Operable
    </li>
    <li role="tab" aria-controls="panel-understandable">
      Understandable
    </li>
    <li role="tab" aria-controls="panel-robust">
      Robust
    </li>
  </ul>
  <div class="deque-tabpanel-group">
    <div role="tabpanel" id="panel-perceivable">
      Making content <em>perceivable</em> means making the <strong>output</strong>
      available to the user's senses, namely sight, sound, and touch (in the case
      of people who use Braille output devices). We won't worry about tasting or
      smelling web pages!
    </div>
    <div role="tabpanel" id="panel-operable">
      Making content <em>operable</em> means making the <strong>input mechanisms</strong>
      robust enough to accept a wide range of devices and methods, including keyboard,
      mouse, touch, gestures, single-switch devices, and so on.
    </div>
    <div role="tabpanel" id="panel-understandable">
      Making content <em>understandable</em> means making the message and the interface
      easy to use and comprehend.
    </div>
    <div role="tabpanel" id="panel-robust">
      Making content <em>robust</em> means ensuring it works across a wide range of
      devices, with both forward and backward compatibility.
    </div>
  </div>
</div>

JavaScript

Required: The complete JavaScript file (for all patterns in the library): deque-patterns.min.js

Key parts of the JavaScript file

Note: The code below is functional only in the context of the complete JavaScript file.

In the @tabpanel section:


      function createTabpanel(widget, config) {

        var widgetTablist = widget.querySelector('[role=tablist]');
        widgetTablist.classList.add('deque-tabpanel-tablist');

        var pause = widget.querySelector('.deque-tabpanel-button-bar');
        if (pause && !pause.classList.contains('hidden')) {
          pause.click();
        }

        function initializeTabs(widget) {
          var tabs = widget.querySelectorAll('[role=tab]');
          for (var i = 0; i < tabs.length; i++) {
            tabs[i].classList.add('deque-tabpanel-tab');
            if (i == 0) {
              tabs[i].setAttribute('aria-selected', 'true');
              tabs[i].setAttribute('tabindex', '0');
            } else {
              tabs[i].setAttribute('aria-selected', 'false');
              tabs[i].setAttribute('tabindex', '-1');
            }
          }
          return tabs;
        }
        var tabs = initializeTabs(widget);

        function initializePanels(widget) {
          var panels = widget.querySelectorAll('[role=tabpanel]');
          for (var i = 0; i < panels.length; i++) {
            panels[i].classList.add('deque-tabpanel-tabpanel');
            if (i != 0) {
              panels[i].classList.add('deque-hidden');
            }
          }
          return panels;
        }
        var panels = initializePanels(widget);

        // create a live region to toss tab-panel-related announcements into
        var region = (0, _containerUtils.createLiveRegion)();
        document.body.appendChild(region);

        var autoplayControls;
        var autoplayConfig;
        if (config.autoplay) {
          autoplayConfig = {
            onPlay: function onPlay() { },
            onPause: function onPause() { },
            onNext: function onNext() {
              var nextTab = getNext(getCurrentTab(tabs));
              selectTab(nextTab, tabs, panels);
              region.notify(nextTab.innerText + 'tab');
            },
            onPrevious: function onPrevious() {
              var previousTab = getPrev(getCurrentTab(tabs));
              selectTab(previousTab, tabs, panels);
              region.notify(previousTab.innerText + 'tab');
            }
          };

          autoplayControls = (0, _carouselControls.activateCarouselControls)(widget, region, autoplayConfig, config.autoplay || 3000);
        }

        function selectTab(selectedTab, tabs, panels) {
          var selectedTabLabeledBy = selectedTab.getAttribute('aria-controls');

          for (var i = 0; i < panels.length; i++) {
            if (panels[i].id === selectedTabLabeledBy) {
              panels[i].classList.remove('deque-hidden');
            } else {
              panels[i].classList.add('deque-hidden');
            }
          }

          for (var j = 0; j < tabs.length; j++) {
            if (tabs[j].getAttribute('aria-controls') === selectedTabLabeledBy) {
              tabs[j].setAttribute('tabindex', '0');
              tabs[j].setAttribute('aria-selected', 'true');
            } else {
              tabs[j].setAttribute('tabindex', '-1');
              tabs[j].setAttribute('aria-selected', 'false');
            }
          }
        }

        function applyNavigationLogic(tabs, panels, _ref) {
          var autoselect = _ref.autoselect,
            vertical = _ref.vertical;


          var tabstopConfig = {
            onSpace: function onSpace(item) {
              var selectedTabLabeledBy = item.getAttribute('aria-controls');
              for (var i = 0; i < panels.length; i++) {
                if (panels[i].id === selectedTabLabeledBy) {
                  panels[i].classList.remove('deque-hidden');
                } else {
                  panels[i].classList.add('deque-hidden');
                }
              }
            },
            onClick: function onClick(item) {
              var selectedTabLabeledBy = item.getAttribute('aria-controls');
              for (var i = 0; i < panels.length; i++) {
                if (panels[i].id === selectedTabLabeledBy) {
                  panels[i].classList.remove('deque-hidden');
                } else {
                  panels[i].classList.add('deque-hidden');
                }
              }
            },
            select: selectTab,
            useAriaSelected: true,
            autoselect: autoselect
          };

          if (vertical) {
            tabstopConfig.onUp = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getPrev(item);
            };

            tabstopConfig.onDown = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getNext(item);
            };
          } else {
            tabstopConfig.onLeft = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getPrev(item);
            };

            tabstopConfig.onRight = function (item) {
              if (autoplayControls) {
                autoplayControls.pause();
              }

              return getNext(item);
            };
          }

          (0, _tabstopUtils.createSingleTabstopStructure)(tabs, panels, tabstopConfig);
        }

        applyNavigationLogic(tabs, panels, config);

        function getCurrentTab(tabs) {
          for (var i = 0; i < tabs.length; i++) {
            if (tabs[i].getAttribute('tabindex') === '0') {
              return tabs[i];
            }
          }
        }

        function getPrev(item) {
          var output = item.previousElementSibling || tabs[tabs.length - 1];
          return output;
        }

        function getNext(item) {
          var output = item.nextElementSibling || tabs[0];
          return output;
        }
      }

      function initializeAllTabpanels() {
        var widgets = document.querySelectorAll('.deque-tabpanel');
        var config;

        for (var i = 0; i < widgets.length; i++) {
          if (widgets[i].id == 'carousel') {
            config = {
              autoselect: true,
              autoplay: 3000
            };
          } else {
            config = {
              autoselect: true,
              autoplay: false
            };
          }
          createTabpanel(widgets[i], config);
        }
      }

      initializeAllTabpanels();
      

In the @tabstopUtils section:


      function createSingleTabstopStructure(tabsNL, panels, config) {
        var tabs = Array.prototype.slice.call(tabsNL);
        tabs.forEach(function (tab) {
          tab.addEventListener('focus', function () {
            if (config.onFocus) {
              config.onFocus(tab);
            }
          });

          if (!config.select) {
            config.select = function () { };
          }

          if (config.onClick) {
            tab.addEventListener('click', function () {
              config.onClick(tab);
              config.select(tab, tabs, panels);
            });
          }

          if (config.onSpace) {
            kb.onElementSpace(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              config.onSpace(tab);
              config.select(tab, tabs, panels);
            });
          }

          if (config.onLeft) {
            kb.onElementLeft(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();

              var target = config.onLeft(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onRight) {
            kb.onElementRight(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onRight(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onUp) {
            kb.onElementUp(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onUp(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onDown) {
            kb.onElementDown(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onDown(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onHome) {
            kb.onElementHome(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onHome(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onEnd) {
            kb.onElementEnd(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onEnd(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onPageUp) {
            kb.onElementPageUp(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onPageUp(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onPageDown) {
            kb.onElementPageDown(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onPageDown(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }

          if (config.onCharacter) {
            kb.onElementCharacter(tab, function (e) {
              e.preventDefault();
              e.stopPropagation();
              var target = config.onCharacter(tab);
              if (target) {
                target.focus();
                if (config.autoselect) {
                  config.select(target, tabs, panels);
                }
              }
            });
          }
        });
      }
      

In the @tabpanel-carouselControls section:


      function createButton(label) {
        var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];

        var output = document.createElement('button');
        output.innerText = label;
        classes.forEach(function (c) {
          return output.classList.add(c);
        });
        return output;
      }

      function buildCarouselControls(_ref) {
        var onPlay = _ref.onPlay,
          onPause = _ref.onPause,
          onNext = _ref.onNext,
          onPrevious = _ref.onPrevious;
        var duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 2000;

        var output = document.createElement('div');
        output.className = 'output-wrap';
        var prevButton = createButton('prev', ['prev']);
        var playButton = createButton('play', ['play', 'hidden']);
        var pauseButton = createButton('pause', ['pause']);
        var nextButton = createButton('next', ['next']);

        // create a live region to toss tab-panel-related announcements into
        var region = (0, _containerUtils.createLiveRegion)();
        document.body.appendChild(region);

        var playInterval = void 0,
          called = false;

        var startPlaying = function startPlaying() {
          if (playInterval) {
            stopPlaying();
          }
          playInterval = setInterval(next, duration);
          playButton.classList.add('hidden');
          pauseButton.classList.remove('hidden');
          pauseButton.focus();
          if (onPlay) {
            onPlay();
          }
          region.notify('Carousel playing');
        };

        var stopPlaying = function stopPlaying() {
          clearInterval(playInterval);
          playInterval = null;
          playButton.classList.remove('hidden');
          if (called) {
            playButton.focus();
          }
          pauseButton.classList.add('hidden');
          if (onPause) {
            onPause();
          }
          called = true;
          region.notify('Carousel paused');
        };

        var next = function next() {
          if (onNext) {
            onNext();
          }
        };

        var prev = function prev() {
          if (onPrevious) {
            onPrevious();
          }
        };

        nextButton.addEventListener('click', next);
        prevButton.addEventListener('click', prev);
        playButton.addEventListener('click', startPlaying);
        pauseButton.addEventListener('click', stopPlaying);

        output.appendChild(prevButton);
        output.appendChild(playButton);
        output.appendChild(pauseButton);
        output.appendChild(nextButton);

        startPlaying();

        output.start = startPlaying;
        output.pause = stopPlaying;
        output.prev = prev;
        output.next = next;
        return output;
      }

      function activateCarouselControls(widget, region, _ref2) {
        var onPlay = _ref2.onPlay,
          onPause = _ref2.onPause,
          onNext = _ref2.onNext,
          onPrevious = _ref2.onPrevious;
        var duration = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 2000;

        var output = widget.querySelector('.deque-tabpanel-button-bar');
        var buttons = output.querySelectorAll('.deque-button');
        var prevButton;
        var playButton;
        var pauseButton;
        var nextButton;

        for (var i = 0; i < buttons.length; i++) {
          if (buttons[i].id == 'prevButton') {
            prevButton = buttons[i];
          } else if (buttons[i].id == 'playButton') {
            playButton = buttons[i];
          } else if (buttons[i].id == 'pauseButton') {
            pauseButton = buttons[i];
          } else if (buttons[i].id == 'nextButton') {
            nextButton = buttons[i];
          }
        }

        playButton.classList.add('deque-hidden');
        playButton.classList.remove('deque-button');

        var playInterval = false;

        var startPlaying = function startPlaying() {
          if (playInterval) {
            stopPlaying();
          }
          playInterval = setInterval(next, duration);
          playButton.classList.add('deque-hidden');
          playButton.classList.remove('deque-button');
          pauseButton.classList.remove('deque-hidden');
          pauseButton.classList.add('deque-button');
          pauseButton.focus();
          if (onPlay) {
            onPlay();
          }
          region.notify('Carousel playing');
        };

        var stopPlaying = function stopPlaying() {
          clearInterval(playInterval);
          playInterval = null;
          playButton.classList.remove('deque-hidden');
          playButton.classList.add('deque-button');
          playButton.focus();
          pauseButton.classList.add('deque-hidden');
          pauseButton.classList.remove('deque-button');
          if (onPause) {
            onPause();
          }

          region.notify('Carousel paused');
        };

        var next = function next() {
          if (onNext) {
            onNext();
          }
        };

        var prev = function prev() {
          if (onPrevious) {
            onPrevious();
          }
        };

        nextButton.addEventListener('click', next);
        prevButton.addEventListener('click', prev);
        playButton.addEventListener('click', startPlaying);
        pauseButton.addEventListener('click', stopPlaying);

        startPlaying();

        output.start = startPlaying;
        output.pause = stopPlaying;
        output.prev = prev;
        output.next = next;
        return output;
      }
      

In the @guidUtils section:


      /*
        note - not a true guid. I prepend 'g' because 
        the ID of an element cannot start with a numeral
      */

      function generateGuid() {
        var S4 = function S4() {
          return ((1 + Math.random()) * 0x10000 | 0).toString(16).substring(1);
        };
        return 'g' + (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4());
      }
      

In the @containerUtils section:


      function elementIsChildOfElement(child, potentialParent) {
        while (child) {
          if (child === potentialParent) {
            return true;
          }

          child = child.parentNode;
        }

        return false;
      }

      function createFieldset(label) {
        var fieldset = document.createElement('fieldset');
        var legend = document.createElement('legend');
        legend.classList.add('legend'); // for easy lookup regardless of mode
        legend.id = (0, _guidUtils.generateGuid)();
        legend.innerText = label;
        fieldset.appendChild(legend);
        return fieldset;
      }

      function createLiveRegion() {
        var level = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'polite';
        var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];

        var output = document.createElement('span');
        classes.forEach(function (c) {
          return output.classList.add(c);
        });
        output.id = (0, _guidUtils.generateGuid)();
        output.setAttribute('aria-live', level);
        output.classList.add('deque-visuallyhidden');
        output.innerText = '';
        output.notify = function (text) {
          // TODO: Clean this up...no need to extend the element prototype
          while (output.firstChild) {
            output.removeChild(output.firstChild);
          }
          var msg = document.createElement('div');
          msg.innerHTML = text;
          output.appendChild(msg);
        };

        return output;
      }
      

In the @keyboardUtils section:


      var KEYS = exports.KEYS = {
        BACKSPACE: 8,
        TAB: 9,
        ENTER: 13,
        SHIFT: 16,
        CTRL: 17,
        ALT: 18,
        ESCAPE: 27,
        SPACE: 32,
        LEFT: 37,
        RIGHT: 39,
        UP: 38,
        DOWN: 40,
        F10: 121,
        HOME: 36,
        END: 35,
        PAGE_UP: 33,
        PAGE_DOWN: 34
      };

      function bindElementToEventValue(element, eventName, testValue, handler) {
        function localHandler(e) {
          if (e.which === testValue) {
            handler(e);
          }
        }

        element.addEventListener(eventName, localHandler);
        return function () {
          element.removeEventListener(eventName, localHandler);
        };
      }

      function bindElementToKeypressValue(element, testValue, handler) {
        return bindElementToEventValue(element, 'keypress', testValue, handler);
      }

      function bindElementToKeydownValue(element, testValue, handler) {
        return bindElementToEventValue(element, 'keydown', testValue, handler);
      }

      function onElementEnter(element, handler) {
        return bindElementToKeydownValue(element, KEYS.ENTER, handler);
      }

      function onElementEscape(element, handler) {
        return bindElementToKeydownValue(element, KEYS.ESCAPE, handler);
      }

      function onElementSpace(element, handler) {
        return bindElementToKeypressValue(element, KEYS.SPACE, handler);
      }

      function onElementLeft(element, handler) {
        return bindElementToKeydownValue(element, KEYS.LEFT, handler);
      }

      function onElementRight(element, handler) {
        return bindElementToKeydownValue(element, KEYS.RIGHT, handler);
      }

      function onElementUp(element, handler) {
        return bindElementToKeydownValue(element, KEYS.UP, handler);
      }

      function onElementDown(element, handler) {
        return bindElementToKeydownValue(element, KEYS.DOWN, handler);
      }

      function onElementHome(element, handler) {
        return bindElementToKeydownValue(element, KEYS.HOME, handler);
      }

      function onElementEnd(element, handler) {
        return bindElementToKeydownValue(element, KEYS.END, handler);
      }

      function onElementPageUp(element, handler) {
        return bindElementToKeydownValue(element, KEYS.PAGE_UP, handler);
      }

      function onElementPageDown(element, handler) {
        return bindElementToKeydownValue(element, KEYS.PAGE_DOWN, handler);
      }

      function onElementF10(element, handler) {
        return bindElementToKeydownValue(element, KEYS.F10, handler);
      }

      function isAlphaNumeric(charCode) {
        return charCode >= 48 && charCode <= 90 /* numbers, uppercase letters */
          || charCode >= 97 && charCode <= 122 /* lowercase letters */;
      }

      function onElementCharacter(element, handler) {
        function localHandler(e) {
          var charCode = e.which;
          if (isAlphaNumeric(charCode)) {
            handler(e);
          }
        }

        element.addEventListener('keypress', localHandler);
        return function () {
          element.removeEventListener('keypress', localHandler);
        };
      }

      function trapEnter(element) {
        onElementEnter(element, function (e) {
          e.stopPropagation();
          e.preventDefault();
        });
      }
      

Note: No additional JavaScript initialization code is necessary for this pattern. All elements with class="deque-tabpanel" will be initialized automatically by the external JavaScript file.

CSS

Required: The complete CSS file (for all patterns in the library): deque-patterns.min.css

Key styles within the CSS file (other styles may also be necessary):


.deque-tabpanel [role='tablist'] {
  list-style-type: none;
  padding: 0;
  display: flex;
  margin: 0 0 20px 0;
  justify-content: center;
}
.deque-tabpanel img {
  max-width: 100%;
}
.deque-tabpanel ul.deque-tabpanel-tablist[role='tablist'] li {
  font-size: 20px;
  display: inline-block;
  flex-shrink: 0;
  font-weight: 200;
  white-space: nowrap;
  padding: 5px 12px;
  cursor: pointer;
}
.deque-tabpanel ul.deque-tabpanel-tablist[role='tablist'] li:focus {
  outline: 1px dashed #000000;
}
.deque-tabpanel li.deque-tabpanel-tab[role='tab'][aria-selected='true'] {
  color: #000000;
  border-bottom: 2px solid;
  border-top: 2px solid;
}
.deque-tabpanel .deque-tabpanel-tabpanels {
  clear: both;
  padding: 0;
  margin: 0;
}
.deque-tabpanel .deque-tabpanel-tabpanel[role='tabpanel'] {
  width: 100%;
}
.deque-tabpanel .deque-tabpanel-button-bar {
  text-align: center;
}
.deque-tabpanel .deque-tabpanel-button-bar .pause,
.deque-tabpanel .deque-tabpanel-button-bar .play {
  margin: 0 15px;
}
@media screen and (max-width: 320px) {
  .deque-tabpanel-tablist {
    display: block !important;
  }
  .deque-tabpanel-tab {
    width: 100% !important;
    box-sizing: border-box;
  }
}

Implementation Instructions

Implement the Tabpanel pattern when you want to provide multiple tabpanels of information (for example, either Landscape, Portrait, or Square) that are focusable via the Tab key and navigable via the arrow keys.

Step 1: Add Dependencies

Add deque-patterns.min.css in the <head> of the document.

<link rel="stylesheet" type="text/css" href="deque-patterns.min.css">

Add a script link to deque-patterns.min.js to the bottom of the page.

<script type="text/javascript" src="deque-patterns.min.js"></script>

Step 2: Add HTML

  • Create a <div> or <span> container with class="deque-tabpanel". If you would like the tabs to automatically cycle through add id="carousel".
    • Make a <ul> container with class="deque-tabpanel-tablist" and role="tablist".
      • List the tabs you want, each within a <li> container. Have them include class="deque-tabpanel-tab", role="tab", a unique id, and a unique aria-controls attribute. The inner text will be the title of the tab.
    • Create a <div> or <span> container with class="deque-tabpanel-tabpanels".
      • List the panels that correspond with the tabs with a <div> or <span> container. These panels are the content that will be displayed when the tab is selected. Each needs to have class="deque-tabpanel-tabpanel" and role="tabpanel". The panel's id is the same as aria-controls of the corresponding tab and there should be an aria-labelledby attribute that is the same as the corresponding tab's id. Within this <div> is the content. That could include text or an <img> container.
    • If your tabpanel is a carousel you can add buttons to control it. Initially, it's in the 'play' state, and the control contains three buttons: "next", "pause" and "previous". If you click pause, it enters the "pause" state and the buttons are "next", "play" and "previous".
    • To create the buttons make a <div> or <span> container with class="deque-tabpanel-button-bar".
      • Each button needs a <button> container. Add class="deque-button" for styling purposes and an id ("prevButton", "playButton", "pauseButton", or "nextButton"). The inner text is the words that appear on the button itself.