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.

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

html

<tab-group>
  <div role="tablist">
    <button role="tab" aria-selected="true">Tab 1</button>
    <button role="tab">Tab 2</button>
    <button role="tab">Tab 3</button>
  </div>

  <div role="tabpanel">
    Shut the hatch before the waves push it in
  </div>
  <div role="tabpanel" hidden>
    Take the winding path to reach the lake
  </div>
  <div role="tabpanel" hidden>
    Write at once or you may forget it
  </div>
</tab-group>
            
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;
}
            

Endnotes

  • I'm not linking to the original video since the technique isn't accessible and I don't want to add to its SEO. It's called "How to Easily Create Pure CSS Tabs (No JavaScript Needed!)" by "dcode" if you want to look it up

Footnotes

  • neo
    Neopoligen

    The website building app I'm currently working on. As I'm writing this the first preview version is out with another more usable version on the way soon

  • gmt
    Go Make Things - Chris Ferdinandi
  • mayank
    Mayank.co

References