Contents

  1. Intro
  2. Implementing The Tabs Pattern With No JavaScript
    1. Step 1: Container
    2. Step 2: Tablist
    3. Step 3: Tabs
    4. Step 4: Panels
    5. Step 5: Functional CSS
    6. Step 6: Aesthetic CSS
  3. Conclusion

Intro

Tabs are ubiquitous in modern software UI, and for good reason - they're really useful. The analogy of tabbed dividers is very natural for many people, even those with little familiarity with technology.

Tabs, like <details> elements, allow for more information to be present on the same page without unduly cluttering things up, enabling users to engage with the content at their own pace. Tabs do more than <details> elements do, though, and are much more variable in how they operate, so there is no standard HTML element to abstract the pattern away from developers.

This means that we see plenty of approaches to implementing them with varying degrees of quality. To help standardize behavior and establish best practices, the Web Accessibility Initiative has released three ARIAAccessible Rich Internet Applications Suite roles to identify common components of the Tabs Pattern and specify expected behavior. They also released documentation of this pattern in the ARIA Authoring Practices Guide (APG), which details expectations and gives a couple examples.

Most of the examples of the pattern, including those in the APG, include writing a non-trivial amount of JavaScript to make the tabs function. This is the kind of scripting I like to avoid writing since it feels a lot like re-inventing the wheel and isn't particularly interesting. Generally speaking, less code is better so there's less that can go wrong. On that note, is it possible to implement the Tabs Pattern without writing a single line of JavaScript?

If we want to mark up the control with the WAI roles, the answer is no. They rely on the aria-selected attribute to indicate which tab is pressed, and the tab role itself is pretty finnicky about what elements it can be on while functioning properly.

However, if we take a page from the Accessibility Developer Guide and forgo the roles in favor of communicating the expected behavior ourselves, the answer can be yes!

Implementing The Tabs Pattern With No JavaScript

The shiny new CSS :has() pseudo-class is really exciting and powerful. Put simply, it allows selecting an element based on the contents of its children, something which greatly expands what's possible with just CSS. It will be our primary tool for implementing the Tabs Pattern without any scripting. We're not really avoiding writing code, but CSS is declarative and generally preferable to relying on scripting when possible.

For this tutorial, let's make a Tab Control to show parts of a D&D character sheet 😊

To give a high-level overview, what we'll be doing is creating an <article> to hold our Tab Control, buliding a tablist out of a <fieldset> using radio buttons (<input type="radio">) for tabs, then using :has() to make our tabpanels (constructed with <article> elements) visible based on which radio button is checked. After that, we'll make everything pretty with some aesthetic CSS. Let's get started!

Step 1: Container

The first thing we want to do is create a wrapper element for our Tab Control. Having this wrapper serves a couple purposes:

It doesn't really matter what kind of element we use for our wrapper, as long as it uses block formatting and permits flow content. Some primary contenders are:

For our example, we'll use an <article>, and we'll give it a semantic class, tab-control. We'll also give it an ID for later.

<article id="veraTabs" class="tab-control"></article>
HTML Snippit

Step 2: Tablist

Next, we'll create our tablist element. Since our tablist is going to be a collection of related form elements, we'll use a <fieldset>, named by a <legend>. We'll also give it a semantic CSS class: tab-list.

<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list">
    <legend>Pages</legend>
  </fieldset>
</article>
HTML Snippit

To identify our tablist as such to assistive tech, we need to give it an accessible description via aria-describedby. Our description will be a <span> with a semantic CSS class, description. We'll also hide it via aria-hidden to prevent it being found outside the context of being a description.

<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list" aria-describedby="spnVeraTLDesc">
    <legend>Pages</legend>
    <span id="spnVeraTLDesc"
      class="description"
      aria-hidden="true"
    >Radio buttons control what following content is shown.</span>
  </fieldset>
</article>
HTML Snippit

Step 3: Tabs

What's a Tab Control without tabs? Next, lets add our radio buttons. Let's say, one for Basic Info, one for Ability Scores, and one for Backstory.

For each tab, we'll create an <input> of type radio, give them all the same name, and each inside an appropriate <label>

<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list" aria-describedby="spnVeraTLDesc">
    <legend>Pages</legend>
    <span id="spnVeraTLDesc"
      class="description"
      aria-hidden="true"
    >Radio buttons control what following content is shown.</span>
    <label><input type="radio" name="page">Basic Info</label>
    <label><input type="radio" name="page">Ability Scores</label>
    <label><input type="radio" name="page">Backstory</label>
  </fieldset>
</article>
HTML Snippit

Step 4: Panels

Next, let's add some content! Each page in a Tab Control is called a tabpanel. Let's create ours after the <fieldset> out of <article> elements. We'll give each a semantic CSS class, tab-panel, and a group role.

<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list" aria-describedby="spnVeraTLDesc">
    <legend>Pages</legend>
    <span id="spnVeraTLDesc"
      class="description"
      aria-hidden="true"
    >Radio buttons control what following content is shown.</span>
    <label><input type="radio" name="page">Basic Info</label>
    <label><input type="radio" name="page">Ability Scores</label>
    <label><input type="radio" name="page">Backstory</label>
  </fieldset>
  <article class="tab-panel" role="group">
    <ul>
      <li>Name: Vera</li>
      <li>Race: Tiefling</li>
      <li>Class: Wizard</li>
      <li>Alignment: Chaotic Good</li>
    </ul>
  </article>
  <article class="tab-panel" role="group">
    <ul>
      <li>Str: 8</li>
      <li>Dex: 12</li>
      <li>Con: 13</li>
      <li>Int: 20</li>
      <li>Wis: 15</li>
      <li>Cha: 12</li>
    </ul>
  </article>
  <article class="tab-panel" role="group">
    <p>
      Vera was born to two human parents in Bluecrest [...]
    </p>
  </article>
</article>
HTML Snippit

But wait, which panel goes with which radio button? We have to specify that in the markup. To do this, we'll use a couple ARIA attributes: aria-controls and aria-labelledby.

aria-controls, when set on the radio buttons, indicates that each particular tab will show the indicated panel. On the flip side, aria-labelledby on our pages will give them appropriate accessible names.

To set aria-controls, we need to first give our pages IDs, then we can hook those IDs up to their tabs.

<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list" aria-describedby="spnVeraTLDesc">
    <legend>Pages</legend>
    <span id="spnVeraTLDesc"
      class="description"
      aria-hidden="true"
    >Radio buttons control what following content is shown.</span>
    <label>
      <input type="radio" name="page" aria-controls="artBasic">
      Basic Info
    </label>
    <label>
      <input type="radio" name="page" aria-controls="artAbilities">
      Ability Scores
    </label>
    <label>
      <input type="radio" name="page" aria-controls="artBackstory">
      Backstory
    </label>
  </fieldset>
  <article id="artBasic" class="tab-panel" role="group">
    <ul>
      <li>Name: Vera</li>
      <li>Race: Tiefling</li>
      <li>Class: Wizard</li>
      <li>Alignment: Chaotic Good</li>
    </ul>
  </article>
  <article id="artAbilities" class="tab-panel" role="group">
    <ul>
      <li>Str: 8</li>
      <li>Dex: 12</li>
      <li>Con: 13</li>
      <li>Int: 20</li>
      <li>Wis: 15</li>
      <li>Cha: 12</li>
    </ul>
  </article>
  <article id="artBackstory" class="tab-panel" role="group">
    <p>
      Vera was born to two human parents in Bluecrest [...]
    </p>
  </article>
</article>
HTML Snippit

Now we need to label our pages. To do this, we'll wrap the text of our radio buttons in <span>s so we can give them IDs to use for aria-labelledby.

<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list" aria-describedby="spnVeraTLDesc">
    <legend>Pages</legend>
    <span id="spnVeraTLDesc"
      class="description"
      aria-hidden="true"
    >Radio buttons control what following content is shown.</span>
    <label>
      <input type="radio" name="page" aria-controls="artBasic">
      <span id="spnBasic">Basic Info</span>
    </label>
    <label>
      <input type="radio" name="page" aria-controls="artAbilities">
      <span id="spnAbilities">Ability Scores</span>
    </label>
    <label>
      <input type="radio" name="page" aria-controls="artBackstory">
      <span id="spnBackstory">Backstory</span>
    </label>
  </fieldset>
  <article id="artBasic"
    class="tab-panel"
    role="group"
    aria-labelledby="spnBasic"
  >
    <ul>
      <li>Name: Vera</li>
      <li>Race: Tiefling</li>
      <li>Class: Wizard</li>
      <li>Alignment: Chaotic Good</li>
    </ul>
  </article>
  <article id="artAbilities"
    class="tab-panel"
    role="group" 
    aria-labelledby="spnAbilities"
  >
    <ul>
      <li>Str: 8</li>
      <li>Dex: 12</li>
      <li>Con: 13</li>
      <li>Int: 20</li>
      <li>Wis: 15</li>
      <li>Cha: 12</li>
    </ul>
  </article>
  <article id="artBackstory"
    class="tab-panel"
    role="group"
    aria-labelledby="spnBackstory"
  >
    <p>
      Vera was born to two human parents in Bluecrest [...]
    </p>
  </article>
</article>
HTML Snippit

Step 5: Functional CSS

Ok great, we've got the markup all in place! Let's see how it looks:

Tab Control Witout CSS

Oops, all our pages are showing at once! How embarrassing. To make the pages hide and show as appropriate, we'll need to write a little CSS.

First, lets hide our pages by default. To do this, we'll select all elements with the tabpanel role inside our semantic tab-control class.

.tab-control {
  .tab-panel {
    display: none;
  }
}
CSS Snippit

Nice. Now, we can write some CSS specific to our particular Tab Control to make the pages show. For each page, we'll need to select our tab container when the appropriate radio button is pressed, then select our descendant page within and show it. We'll start by selecting the tab container when the Basic Info radio button is pressed. It'll look like this:

.tab-control {
  .tab-panel {
    display: none;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) {
  }
}
CSS Snippit

Our :has() selector is looking for an element which has aria-controls set to our page and is :checked. This selector will match the #veraTabs element when that radio button is pressed. Now, in the same selector list, let's select our page, #artBasic. This will mean that we're selecting our page only when its radio button is pressed!

.tab-control {
  .tab-panel {
    display: none;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic {
  }
}
CSS Snippit

Now that we have our page slected just when we want, let's add a declaration which will show it. The obvious way to do this is to restore the default display property for our <div>, block.

.tab-control {
  .tab-panel {
    display: none;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic {
    display: block;
  }
}
CSS Snippit

That should work! Let's go ahead and apply this to the other pages - we can keep everything in the same selector list since how the pages are shown is identical:

.tab-control {
  .tab-panel {
    display: none;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic,
  &:has([aria-controls="artAbilities"]:checked) #artAbilities,
  &:has([aria-controls="artBackstory"]:checked) #artBackstory {
    display: block;
  }
}
CSS Snippit

Finally, let's visually hide our description of the tablist. The visual interaction pattern should be pretty obvious, so we don't need it visually.

.tab-control {
  .tab-panel {
    display: none;
  }

  .description {
    position: absolute;
    left: -99999999px;
    top: 0px;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic,
  &:has([aria-controls="artAbilities"]:checked) #artAbilities,
  &:has([aria-controls="artBackstory"]:checked) #artBackstory {
    display: block;
  }
}
CSS Snippit

Alright, let's see what that looks like!

Tab Control With Functional CSS

Step 6: Aesthetic CSS

We've now implemented a functional Tab Control! It looks a bit plain at the moment, but we can fix that with some tastefull CSS.

First, let's give our whole control some padding and make our tablist a bit more streamlined:

.tab-control {
  padding: 5px;
  
  .tab-list {
    margin-inline: 0;
    margin-block-end: 0;
  }
  
  .tab-panel {
    display: none;
  }

  .description {
    position: absolute;
    left: -99999999px;
    top: 0px;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic,
  &:has([aria-controls="artAbilities"]:checked) #artAbilities,
  &:has([aria-controls="artBackstory"]:checked) #artBackstory {
    display: block;
  }
}
CSS Snippit

Now, let's make our pages a little more visually related to the tablist. (Note, I'm using some site-wide CSS variables from gwBoillerPlatePersonalization.css).

.tab-control {
  padding: 5px;
  
  .tab-list {
    margin-inline: 0;
    margin-block-end: 0;
  }
  
  .tab-panel {
    display: none;
    
    background-color: var(--background-color-2);
    padding: 5px;
    border-inline: 1px solid var(--border-color);
    border-block-end: 1px solid var(--border-color);
  }

  .description {
    position: absolute;
    left: -99999999px;
    top: 0px;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic,
  &:has([aria-controls="artAbilities"]:checked) #artAbilities,
  &:has([aria-controls="artBackstory"]:checked) #artBackstory {
    display: block;
  }
}
CSS Snippit

Finally, lets make which tab is selected a bit more clear:

.tab-control {
  padding: 5px;
  
  .tab-list {
    margin-inline: 0;
    margin-block-end: 0;
    
    label:has(:checked) {
      background-color: var(--selected-color);
    }
  }
  
  .tab-panel {
    display: none;
    
    background-color: var(--background-color-2);
    padding: 5px;
    border-inline: 1px solid var(--border-color);
    border-block-end: 1px solid var(--border-color);
  }

  .description {
    position: absolute;
    left: -99999999px;
    top: 0px;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic,
  &:has([aria-controls="artAbilities"]:checked) #artAbilities,
  &:has([aria-controls="artBackstory"]:checked) #artBackstory {
    display: block;
  }
}
CSS Snippit

I think that will do it! Let's take a look:

Tab Control With Functional and Aesthetic CSS

Conclusion

We now have a completely declarative, accessible Tab Control! Yay! Now that we're here, we can style our control to look like just about anything. The sky's the limit.

The APG does detail some variants of the pattern which can't be covered without JavaScript, namely manual tab activation and some additional keyboard functionality. While we're not covering that functionality here, I believe our mostly-CSS implementation can serve as a foundation which can be built upon with JavaScript rather than replaced by it. While not everything can be done declaratively, it's best to do as much as we can.

For ease of reference, here are the completed HTML and CSS fragments:

.tab-control {
  padding: 5px;
  
  .tab-list {
    margin-inline: 0;
    margin-block-end: 0;
    
    label:has(:checked) {
      background-color: var(--selected-color);
    }
  }
  
  .tab-panel {
    display: none;
    
    background-color: var(--background-color-2);
    padding: 5px;
    border-inline: 1px solid var(--border-color);
    border-block-end: 1px solid var(--border-color);
  }

  .description {
    position: absolute;
    left: -99999999px;
    top: 0px;
  }
}

#veraTabs {
  &:has([aria-controls="artBasic"]:checked) #artBasic,
  &:has([aria-controls="artAbilities"]:checked) #artAbilities,
  &:has([aria-controls="artBackstory"]:checked) #artBackstory {
    display: block;
  }
}
Completed CSS Snippit
<article id="veraTabs" class="tab-control">
  <fieldset class="tab-list" aria-describedby="spnVeraTLDesc">
    <legend>Pages</legend>
    <span id="spnVeraTLDesc"
      class="description"
      aria-hidden="true"
    >Radio buttons control what following content is shown.</span>
    <label>
      <input type="radio" name="page" aria-controls="artBasic">
      <span id="spnBasic">Basic Info</span>
    </label>
    <label>
      <input type="radio" name="page" aria-controls="artAbilities">
      <span id="spnAbilities">Ability Scores</span>
    </label>
    <label>
      <input type="radio" name="page" aria-controls="artBackstory">
      <span id="spnBackstory">Backstory</span>
    </label>
  </fieldset>
  <article id="artBasic"
    class="tab-panel"
    role="group"
    aria-labelledby="spnBasic"
  >
    <ul>
      <li>Name: Vera</li>
      <li>Race: Tiefling</li>
      <li>Class: Wizard</li>
      <li>Alignment: Chaotic Good</li>
    </ul>
  </article>
  <article id="artAbilities"
    class="tab-panel"
    role="group"
    aria-labelledby="spnAbilities"
  >
    <ul>
      <li>Str: 8</li>
      <li>Dex: 12</li>
      <li>Con: 13</li>
      <li>Int: 20</li>
      <li>Wis: 15</li>
      <li>Cha: 12</li>
    </ul>
  </article>
  <article id="artBackstory"
    class="tab-panel"
    role="group"
    aria-labelledby="spnBackstory"
  >
    <p>
      Vera was born to two human parents in Bluecrest [...]
    </p>
  </article>
</article>
Completed HTML Snippit

Please let me know if you've found this tutorial useful or if you have feedback in the comments below!