Skip to main content

TabList v2.0.0

Organize content into separate sections, allowing user to switch between them by selecting different tabs.

Habitant morbi

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.

Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.

Vestibulum Tortor Quam

Feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.

Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat.

Malesuada Fames

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus

Note: By default, tabs display inline on both mobile and desktop, wrapping as needed based on screen size.

Reminder: Do not confuse a tabbed interface with a horizontal accordion—they are not the same. The key difference lies in their behavior. If you expect content sections to always close when a tab button is pressed, then please use the Accordion component. However, a tabbed UI should have only a few tabs. If there are too many, then you should consider a horizontal accordion instead.

Tab Placement: Respect the Natural Flow Anchor (Tab Placement: Respect the Natural Flow)

Placing tab controls below the content they manage creates a disconnect between the control and its content, resulting in a confusing and inaccessible experience across keyboard, mobile, and magnified views.

This pattern creates multiple usability and accessibility issues:

  • Breaks expected keyboard flow: Users navigate left-to-right, top-to-bottom. When tabs are at the bottom, focus moves away from the active panel instead of into it, forcing users to tab backwards which is not predictable and violates WCAG 3.2.3 Consistent Navigation, WCAG 2.4.3 Focus Order
  • Poor mobile usability: Content updates occur outside the user’s current view, making changes easy to miss.
  • Screen magnification barriers: Magnified users are likely focused on the tabs; updated content appears above and may not be visible, and users may scroll the wrong direction.
  • Focus and spatial disorientation: If panel height changes, activating a tab can shift the tabs’ position, causing users to lose their place.
  • Violates established tab design conventions: Tabs are expected to precede and control the content that follows.

Example

Line art illustration showing two tablist examples: one with tabs above the content (correct), and one with tabs below the content (incorrect).
<div class="tablist">

  <ul class="tablist__list">
    <li><a class="tablist__tab" href="#default-setup-1">Habitant Morbi</a></li>
    <li><a class="tablist__tab" href="#default-setup-2">Vestibulum Tortor Quam</a></li>
    <li><a class="tablist__tab" href="#default-setup-3">Malesuada Fames</a></li>
  </ul>

  <div class="tablist__panel" id="default-setup-1">

    <div class="tablist__content">

      <h2>Habitant morbi</h2>

      <p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.</p>

      <p>Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.</p>

    </div>

  </div>

  <div class="tablist__panel" id="default-setup-2">

    <div class="tablist__content">

      <h2>Vestibulum Tortor Quam</h2>

      <p>Feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus.</p>

      <p>Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat.</p>

    </div>

  </div>

  <div class="tablist__panel" id="default-setup-3">

    <div class="tablist__content">

      <h2>Malesuada Fames</h2>

      <p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis. Praesent dapibus, neque id cursus faucibus, tortor neque egestas augue, eu vulputate magna eros eu erat. Aliquam erat volutpat. Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus</p>

    </div>

  </div>

</div>
/*!

  Radancy Component Library: TabList

  Contributor(s): 
  Michael "Spell" Spellacy

  Dependencies: Sass

*/

.tablist {

// Component Variables

--tablist-color-active: #ffffff; // Active State
--tablist-color-default: #d3d3d3; // Default States
--tablist-color-interactive: #a9a9a9; // Interactive State 
--tablist-color-selected: #6F00EF; // Selected States

// Breakpoints (CSS variables are not allowed in media queries - boo!)

$tablist-large: 55em; // Large Viewport
$tablist-medium: 48.625em; // Medium Viewport

color: var(--tablist-color-active);
position: relative;

  &:has([aria-orientation]) {

    @media(min-width: $tablist-large) {
  
      display: grid;
      grid-template-columns: calc(280rem /16) auto;

    }
  
  }

  // Tablist Container (Optional)

  &__container {

  }

  // Tablist Heading (Optional)

  &__heading {
  color: var(--tablist-color-selected);
  font-size: 1.9rem;
  text-align: center;
  width: auto;
  }

  &__container, &__list {
  align-self: center;
  }

  &__list {
  display: flex;
  flex-wrap: wrap;
  gap: calc(4rem/16);
  list-style: none;
  margin: 0;
  padding: 0;

    &[aria-orientation] {

      @media(min-width: $tablist-large) {

        display: block; 

      }

    }

    [data-stack] & {

      @media(max-width: $tablist-medium) {

        display: block;

      }

    }

    [data-scroll] & {

      // This max-width can be changed depending on amount of list items you have.

      @media(max-width: $tablist-medium) {

        flex-wrap: nowrap;
        overflow-x: auto;
        scroll-snap-type: inline mandatory;
        white-space: nowrap;

        // Fallback Scrollbar

        scrollbar-color: var(--tablist-color-active) var(--tablist-color-selected);
        scrollbar-width: thin;

        @supports #{"\selector(::-webkit-scrollbar)"} {

          scrollbar-color: initial;
          scrollbar-width: auto;
    
          &::-webkit-scrollbar {
          height: calc(20rem / 16);
          }
      
          &::-webkit-scrollbar-track {
          background-color: var(--tablist-color-selected);
          }
      
          &::-webkit-scrollbar-thumb {
          background-color: var(--tablist-color-active);
          border: calc(8rem / 16) var(--tablist-color-selected) solid;
          border-radius: 100vw;
          }
        
        }

      }

    }

    li {
    flex-grow: 1;
    scroll-snap-align: start;

      @media(min-width: $tablist-medium) {

        flex-grow: 0;

      }

    }

  }

  // Tab Button

  &__tab {
  background-color: var(--tablist-color-default);
  color: #000;
  display: block;
  padding: 1em;
  text-align: center;
  text-decoration: none;

    [data-animation] & {

      @media (prefers-reduced-motion: no-preference) {

        transition: 600ms;

      }

    }

    &:hover {
    background-color: var(--tablist-color-interactive);
    }

    &[aria-selected="true"] {
    background-color: var(--tablist-color-selected);
    color: var(--tablist-color-active);
    }

  }

  // Tab Panel

  &__panel  {
  background-color: var(--tablist-color-selected);
  overflow: hidden;
  padding: 1em;
  position: relative;

    [data-fixed-height] & {

      max-height: calc(400em/16);
      overflow-y: auto;
      overscroll-behavior-y: contain;
      scrollbar-gutter: stable;

      // Fallback Scrollbar

      scrollbar-color: var(--tablist-color-active) var(--tablist-color-selected);
      scrollbar-width: thin;

      @supports #{"\selector(::-webkit-scrollbar)"} {

        scrollbar-color: initial;
        scrollbar-width: auto;
        padding-inline-end: 0.5em;
  
        &::-webkit-scrollbar {
        width: calc(18rem / 16);
        }
    
        &::-webkit-scrollbar-track {
        background-color: var(--tablist-color-selected);
        margin-block: 0.5em;
        }
    
        &::-webkit-scrollbar-thumb {
        background-color: var(--tablist-color-active);
        border: calc(6rem / 16) var(--tablist-color-selected) solid;
        border-radius: 100vw;
        min-height: calc(100rem / 16);
        }
      
      }

    }
  
  }

  // Tab Content

  &__content { 
  position: relative;

    // Your specific styles, if any.

    [data-animation] & {

      :not([hidden]) & {

        inset-block-start: 0;
        opacity: 1;
  
        @starting-style {
        
          inset-block-start: 3em;
          opacity: 0;
        
        }

        @media (prefers-reduced-motion: no-preference) {

          transition: inset 600ms, opacity 300ms;
  
        }
    
      }

    }

  }

  // Focus State

  // Accessibility: Never remove focus outlines! Either redesign the outline or design an alternative focus state. 
  // However, a 3:1 ratio will be required. Learn: https://webaim.org/articles/contrast/

  &__tab, &__panel {

    &:focus {
    outline-offset: -6px;
    }

  }

}
/*!

  Radancy Component Library: TabList

  Contributor(s):
  Michael "Spell" Spellacy

*/

(() => {

  "use strict";

  const initTablist = () => {

    // Classes, data attributes, states, and strings.

    const tabListClass = ".tablist";
    const tabListListClass = ".tablist__list";
    const tabListClassName = "tablist__tab";
    const tabListTabClass = `.${tabListClassName}`;
    const tabListDataDisableAnchor = "data-disable-anchor";
    const tabListDataVertical = "data-vertical";
    const tabListPanelClass = ".tablist__panel";
    const tabLists = document.querySelectorAll(tabListClass);
    const URLFragment = location.hash.slice(1);

    // Display which version is in use via console:

    console.log("%cTabList%cv2.0.0", "background: #2d2d2d; color: #fff; padding: 6px 10px; border-radius: 16px 0 0 16px; font-weight: 600;" , "background: #6e00ee; color: #fff; padding: 6px 10px; border-radius: 0 16px 16px 0; font-weight: 600;");

    // Set up all Tablists on page

    tabLists.forEach((list, e) => {

      const index = e + 1;

      list.setAttribute("id", `tablist-${index}`);

      const listContainer = list.querySelector(tabListListClass);

      listContainer.setAttribute("role", "tablist");

      if (list.hasAttribute(tabListDataVertical)) {

        listContainer.setAttribute("aria-orientation", "vertical");

      }

      // Get all tabs within tablist.

      const tabs = list.querySelectorAll(tabListTabClass);

      // Get all tabpanels within tablist.

      const panels = list.querySelectorAll(tabListPanelClass);

      // Array used to hold all panel IDs.

      const panelNames = [];

      // Prep each tab.

      tabs.forEach((tab) => {

        const tabID = tab.getAttribute("href").replace("#", "");

        // Push all panel ID's to array.

        panelNames.push(tabID);

        // Setup each tab.

        tab.parentElement.setAttribute("role", "presentation");
        tab.setAttribute("aria-controls", tabID);
        tab.setAttribute("id", `tab-${tabID}`);
        tab.setAttribute("role", "tab");
        tab.setAttribute("aria-selected", "false");
        tab.setAttribute("tabindex", "-1");

        // Highlight tab based on hash.

        if (tabID === URLFragment) {

          tab.setAttribute("aria-selected", "true");
          tab.removeAttribute("tabindex");

        }

        tab.addEventListener("click", (e) => {

          const panelID = tab.getAttribute("href").replace("#", "");

          tabs.forEach((tab) => {

            tab.setAttribute("aria-selected", "false");
            tab.setAttribute("tabindex", "-1");

          });

          // Hide any open panels

          const panels = tab.closest(tabListClass).querySelectorAll(tabListPanelClass);
          const panelTarget = tab.closest(tabListClass).querySelector(`#${panelID}`);

          panels.forEach((panel) => {

            panel.setAttribute("hidden", "");
            panel.removeAttribute("tabindex");

          });

          // Show selected panel

          panelTarget.removeAttribute("hidden");
          panelTarget.setAttribute("tabindex", "0");

          // Highlight selected tab

          tab.setAttribute("aria-selected", "true");
          tab.removeAttribute("tabindex");

          if (!tab.closest(tabListClass).hasAttribute(tabListDataDisableAnchor)) {

            history.pushState(null, null, `#${tabID}`);
    
          }

          e.preventDefault();

        });

        // Get tab links used in tablist.

        const tabLinks = list.querySelector(tabListListClass).querySelectorAll(tabListTabClass);

        tab.addEventListener("keydown", (e) => {

          // Check if the key pressed is an arrow key or Home/End

          if (["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft", "Home", "End"].includes(e.key)) {

            e.preventDefault(); // Prevent default scrolling behavior when arrows keys clicked.

            // Get the index of the currently focused link

            let focusedIndex = Array.from(tabLinks).indexOf(document.activeElement);

            // Calculate the new index based on the arrow key pressed

            if (["ArrowLeft", "ArrowUp"].includes(e.key)) {

              focusedIndex = (focusedIndex - 1 + tabLinks.length) % tabLinks.length; // Move left

            } else if (["ArrowRight", "ArrowDown"].includes(e.key)) {

              focusedIndex = (focusedIndex + 1) % tabLinks.length; // Move right

            } else if (e.key === "Home") {

              focusedIndex = 0; // Move to first tab

            } else if (e.key === "End") {

              focusedIndex = tabLinks.length - 1; // Move to last tab

            }

            // Focus and trigger event

            tabLinks[focusedIndex].focus();
            tabLinks[focusedIndex].click();

          }

        });

      });

      panels.forEach((panel) => {

        const panelID = panel.getAttribute("id");

        panel.setAttribute("aria-labelledby", `tab-${panelID}`);
        panel.setAttribute("role", "tabpanel");
        panel.setAttribute("tabindex", "0");
        panel.setAttribute("hidden", "");

        if (panelID === URLFragment) {

          panel.removeAttribute("hidden");

        }

      });

      if (!URLFragment || !panelNames.includes(URLFragment)) {

        tabs[0].setAttribute("aria-selected", "true");
        tabs[0].removeAttribute("tabindex");
    
        panels[0].removeAttribute("hidden");
        panels[0].setAttribute("tabindex", "0");

      }

    });

    // All TabList panels must have unique IDs. Check to see if duplicate ID's exist and alert developer.
    // Note: This function may not be needed. Instead, simply having functionality so dependent on ID, may make this moot.

    const allPanelID = [];
    const tabPanels = document.querySelectorAll(tabListPanelClass);

    tabPanels.forEach((panel) => {

      const panelID = panel.id;
      
      allPanelID.push(panelID);

    });

    allPanelID.forEach((element, index, array) => {

      // Check if the current element is equal to any subsequent element

      array.slice(index + 1).forEach((nextElement) => {

        if (element === nextElement) {

          console.log(`%c Warning: Duplicate Tablist ID found: #${element}. Tab panels must only contain unique ID values. `, "background: #ff0000; color: #fff");

        }

      });

    });

  }

  initTablist();

})();

Properties Anchor (Properties)

Use the following properties to configure this component.

Attribute Applies To Note Description
data-animation .tablist Boolean, Optional
Used to enable content to be animated when opened. Please note that the default animation leverages @starting-style at-rule.
data-disable-anchor .tablist Boolean, Optional
Used to disable the anchor that appears in the address bar when a tab is clicked on. Do note that including an anchor can be useful for tracking and sharing specific content, so we should only remove when we have a good reason.
data-fixed-height .tablist Boolean, Optional
Used to fix the tab content to a specific height. The height may be adusted in the CSS.
data-scroll .tablist Boolean, Optional
Used to change the tabs into a scroll in smaller viewports. While scrolling is always an option, it should only be done sparingly. Allowing tabs to stack is always the better choice.
data-stack .tablist Boolean, Optional
Used to allow the tabs to stack in smaller viewports. The default behavior of tabs are to wrap.
data-vertical .tablist Boolean, Optional
Used to change the layout of the tabs from horizontal (default) to vertical. The CSS for this layout is tied to aria-orientation, which does not have wide assisitve technology support yet.

Class Applies To Note Description
.tablist div Mandatory
Used on the parent element that wraps your list and panels.
.tablist__list ul Mandatory
Used on the ul element for each list. Must be a child of .tablist.
.tablist__tab a Mandatory
Used on the a element for each tab. Must be a child of .tablist__list.
.tablist__panel div Mandatory
Used on the parent element that wraps your content. Must be a child of .tablist.
.tablist__content div Optional
Used on the element that directly wraps your content. Must be a child of .tablist__panel.

Release Notes Anchor (Release Notes)

Keep up to date on major changes to this component.

Feature
March 30, 2026: Added support for Home and End. When either key is pressed within the component, focus will move to the first or last tab, respectively.
Note
April 24, 2025: Upgraded component to ES6. Thanks, Donte! Added gap to tabs to make them look more distinct from accordion. Tabs should look like tabs.

Report Issues

Find a bug? Want a new feature? Report it on JIRA. You're so awesome!