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 helps us standardize CSS styling if we make more controls.
It helps us bound our selectors so we don't accidentally style something unrelated.
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:
<article>
<section>
<figure>
<div>
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.
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.
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.
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>
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.
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.
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.
Step 5: Functional CSS
Ok great, we've got the markup all in place! Let's see how it looks:
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.
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:
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!
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.
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:
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.
Alright, let's see what that looks like!
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:
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).
Finally, lets make which tab is selected a bit more clear:
I think that will do it! Let's take a look:
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:
Please let me know if you've found this tutorial useful or if you have feedback in the comments below!