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
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 withclass="deque-tabpanel"
. If you would like the tabs to automatically cycle through addid="carousel"
. - Make a
<ul>
container withclass="deque-tabpanel-tablist"
androle="tablist"
. - List the tabs you want, each within a
<li>
container. Have them includeclass="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 withclass="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 haveclass="deque-tabpanel-tabpanel"
androle="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 withclass="deque-tabpanel-button-bar"
. - Each button needs a
<button>
container. Addclass="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.