Skip to main content Accessibility Feedback

<toggle-tabs>

Create accessible toggle tab components.

Source Code

Basic Usage

Wrap a list of anchor links and matching content (with IDs) in the <toggle-tabs> element.

The list of anchor links should have a [tabs] attribute on it.

<toggle-tabs>
    <ul tabs>
        <li><a href="#wizard">Wizard</a></li>
        <li><a href="#sorcerer">Sorcerer</a></li>
        <li><a href="#druid">Druid</a></li>
    </ul>

    <div id="wizard">
        Wizards gain their magic through study...
    </div>

    <div id="sorcerer">
        Sorcerers get their power from an otherworldly being...
    </div>

    <div id="druid">
        Druids get their power from nature.
    </div>
</toggle-tabs>

When the Web Component loads, it will hide the content, add required controls and ARIA attributes, and add event listeners to selectively show or hide the content when a tab is clicked or required keyboard interactions occur.

Styling

The Web Component loads a global <style> element that includes the minimum required styles to provide appropriate visual affordance.

You can add additional styles as desired, and modify pre-defined styles using CSS variables.

toggle-tabs {

    /* The navigation list  */
    --toggle-tab-list-margin: 0 0 2em;

    /* Toggle nav links */
    --toggle-tab-link-color: currentColor;
    --toggle-tab-link-margin: 0 0 0.25em;
    --toggle-tab-link-padding: 0.5em 1em;

    /* Toggle nav link :hover */
    --toggle-tab-list-hover-bg-color: #f7f7f7;

    /* The active toggle nav link */
    --toggle-tab-link-active-bg-color: #e5e5e5;

}

The Web Component

customElements.define('toggle-tabs', class extends HTMLElement {

    /**
     * Instantiate the Web Component
     */
    constructor () {

        // Get parent class properties
        super();

        // Define properties
        this.tabList = this.querySelector('[tabs]');

        // Setup UI
        this.setupDOM();
        this.loadCSS();
        this.clickHandler = this.createClickHandler();
        this.keyHandler = this.createKeyHandler();

    }

    /**
     * Add buttons and hide content on page load
     */
    setupDOM () {

        // Only run if there are tabs
        if (!this.tabList) return;

        // Get the list items and links
        let listItems = this.tabList.querySelectorAll('li');
        let links = this.tabList.querySelectorAll('a');

        // Add ARIA to list
        this.tabList.setAttribute('role', 'tablist');

        // Add ARIA to the list items
        for (let item of listItems) {
            item.setAttribute('role', 'presentation');
        }

        // Add ARIA to the links and content
        let instance = this;
        links.forEach(function (link, index) {

            // Get the the target element
            let tabPane = instance.querySelector(link.hash);
            if (!tabPane) return;

            // Add [role] and [aria-selected] attributes
            link.setAttribute('role', 'tab');
            link.setAttribute('aria-selected', index === 0 ? true : false);

            // If it's not the active (first) tab, remove focus
            if (index > 0) {
                link.setAttribute('tabindex', -1);
            }

            // If there's no ID, add one
            if (!link.id) {
                link.id = `tab_${tabPane.id}`;
            }

            // Add ARIA to tab pane
            tabPane.setAttribute('role', 'tabpanel');
            tabPane.setAttribute('aria-labelledby', link.id);

            // If not the active pane, hide it
            if (index > 0) {
                tabPane.setAttribute('hidden', '');
            }

        });

    }

    /**
     * Load accordion styles
     */
    loadCSS () {
        if (document.querySelector('[data-toggle-tabs-css]')) return;
        let css = document.createElement('style');
        css.innerHTML =
            `toggle-tabs [role="tablist"] {
                list-style: none;
                margin: var(--toggle-tab-list-margin, 0 0 2em);
                padding: 0;
            }

            toggle-tabs [role="tablist"] li {
                display: inline-block;
            }

            toggle-tabs [role="tab"] {
                color: var(--toggle-tab-link-color, currentColor);
                margin: var(--toggle-tab-link-margin, 0 0 0.25em);
                padding:  var(--toggle-tab-link-padding, 0.5em 1em);
                text-decoration: none;
            }

            toggle-tabs [role="tab"]:active,
            toggle-tabs [role="tab"]:hover {
                background-color: var(--toggle-tab-list-hover-bg-color, #f7f7f7);
            }

            toggle-tabs [role="tab"][aria-selected="true"] {
                background-color: var(--toggle-tab-link-active-bg-color, #e5e5e5);
            }`;
        css.setAttribute('data-toggle-tabs-css', '');
        document.head.append(css);
    }

    /**
     * Create the event handler
     */
    createClickHandler () {
        return (event) => {

            // Only run on tab links
            if (!event.target.matches('[role="tab"]')) return;

            // Prevent the link from updating the URL
            event.preventDefault();

            // Ignore the currently active tab
            if (event.target.matches('[aria-selected="true"]')) return;

            // Toggle tab visibility
            this.toggle(event.target);

        };
    }

    createKeyHandler () {
        return (event) => {

            // Only run for left and right arrow keys
            if (!['ArrowLeft', 'ArrowRight'].includes(event.code)) return;

            // Only run if element in focus is on a tab
            let tab = document.activeElement.closest('[role="tab"]');
            if (!tab) return;

            // Only run if focused tab is in this component
            if (!this.tabList.contains(tab)) return;

            // Get the currently active tab
            let currentTab = this.tabList.querySelector('[role="tab"][aria-selected="true"]');

            // Get the parent list item
            let listItem = currentTab.closest('li');

            // If right arrow, get the next sibling
            // Otherwise, get the previous
            let nextListItem = event.code === 'ArrowRight' ? listItem.nextElementSibling : listItem.previousElementSibling;
            if (!nextListItem) return;
            let nextTab = nextListItem.querySelector('a');

            // Toggle tab visibility
            this.toggle(nextTab);
            nextTab.focus();

        };
    }

    /**
     * Toggle tab visibility
     * @param  {Node} tab The tab to show
     */
    toggle (tab) {

        // Get the target tab pane
        let tabPane = this.querySelector(tab.hash);
        if (!tabPane) return;

        // Get the current tab and content
        let currentTab = tab.closest('[role="tablist"]').querySelector('[aria-selected="true"]');
        let currentPane = document.querySelector(currentTab.hash);

        // Update the selected tab
        tab.setAttribute('aria-selected', true);
        currentTab.setAttribute('aria-selected', false);

        // Update the visible tabPane
        tabPane.removeAttribute('hidden');
        currentPane.setAttribute('hidden', '');

        // Make sure current tab can be focused and other tabs cannot
        tab.removeAttribute('tabindex');
        currentTab.setAttribute('tabindex', -1);

    }

    /**
     * Start listening to clicks
     */
    connectedCallback () {
        if (!this.tabList) return;
        this.tabList.addEventListener('click', this.clickHandler);
        document.addEventListener('keydown', this.keyHandler);
    }

    /**
     * Stop listening to clicks
     */
    disconnectedCallback () {
        this.tabList.removeEventListener('click', this.clickHandler);
        document.removeEventListener('keydown', this.keyHandler);
    }

});