Accessible HTML Tabs For Web Pages

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

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 Thingsgmt and Mayank from mayank.comayank 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
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