Home
NOTE: I'm in the middle of upgrading the site. Most things are in place, but some things are missing and/or broken. This includes alt text for images. Please bear with me while I get things fixed.

Accessible HTML Tabs For Web Pages

I'm adding tabs to show different examples for a color picker in Neopoligen neo .

I found a video on how to make "CSS only" tabs that looked good but never mentioned accessibility. I checked with folks on a few discords for guidance and learned it's got problems. Specifically, it's not currently possible to make CSS only tabs in an accessible way.

Chris Ferdinandi from Go Make Things gmt and Mayank from mayank.co mayank sent over some better alternatives. Here's Mayank's approach with some specific styles added into the CSS below.

Note

This is my local copy of the code for my notes. I've added nothing new here except a few styles. Be sure to visit Mayank's post for the original code and details

Shut the hatch before the waves push it in

JavaScript

class TabGroup extends HTMLElement {
  get tabs() {
    return [...this.querySelectorAll('[role=tab]')];
  }

  get panels() {
    return [...this.querySelectorAll('[role=tabpanel]')];
  }

  get selected() {
    return this.querySelector('[role=tab][aria-selected=true]');
  }

  set selected(element) {
    this.selected?.setAttribute('aria-selected', 'false');
    element?.setAttribute('aria-selected', 'true');
    element?.focus();
    this.updateSelection();
  }

  connectedCallback() {
    this.generateIds();
    this.updateSelection();
    this.setupEvents();
  }

  generateIds() {
    const prefix = Math.floor(Date.now()).toString(36);
    this.tabs.forEach((tab, index) => {
      const panel = this.panels[index];
      tab.id ||= `${prefix}-tab-${index}`;
      panel.id ||= `${prefix}-panel-${index}`;
      tab.setAttribute('aria-controls', panel.id);
      panel.setAttribute('aria-labelledby', tab.id);
    });
  }

  updateSelection() {
    this.tabs.forEach((tab, index) => {
      const panel = this.panels[index];
      const isSelected = tab.getAttribute('aria-selected') === 'true';
      tab.setAttribute('aria-selected', isSelected ? 'true' : 'false');
      tab.setAttribute('tabindex', isSelected ? '0' : '-1');
      panel.setAttribute('tabindex', isSelected ? '0' : '-1');
      panel.hidden = !isSelected;
    });
  }

  setupEvents() {
    this.tabs.forEach((tab) => {
      tab.addEventListener('click', () => this.selected = tab);
      tab.addEventListener('keydown', (e) => {
        if (e.key === 'ArrowLeft') {
          this.selected = tab.previousElementSibling ?? this.tabs.at(-1);
        } else if (e.key === 'ArrowRight') {
          this.selected = tab.nextElementSibling ?? this.tabs.at(0);
        }
      });
    });
  }
}

customElements.define('tab-group', TabGroup);

CSS

:root {
  --color-selected: rgb(255 255 255);
  --color-not-selected: rgb(255 255 255 / .6);
}

[role="tab"] {
  background: none;
  border: none;
  color: var(--color-not-selected);
  cursor: pointer;
  font: inherit;
  outline: inherit;
  padding-block: 0 2px;
  padding-inline: 11px;
  &[aria-selected='true'] {
    border-bottom: 3px solid var(--color-selected);
    color: var(--color-selected);
    padding-block: 0 0;
  }
}

[role="tablist"] {
  border-bottom: 1px solid var(--color-selected);
}

[role="tabpanel"] {
  padding: 0.7rem;
}
~ fin ~

Endnotes

Footnotes

References