The tabs adventure

Ross Angus
14 min readApr 10, 2019

--

Tabs are a solved problem, right? WRONG.

Recipe index cards dividers by Roy Guisinger.

For a user interface which stretches back to the 1970s (in digital form, at least), you might be surprised how little consensus there is about how they work. You might not agree with everything you’re about to read, but I can 100% guarantee* you will learn something.

* All guarantees come with the understanding that you know less than I do, in this particular area.

The markup

Rather than reading through the ARIA accessibility specs, let’s get a piggyback from a giant: the WAI-ARIA Authoring Practice guide. Here’s their version of tabs. Job done, right? Hold up, bucko. There’s also The A11Y Project. Unfortunately, their simple tabs have a jQuery dependency, but their Responsive Tabs come in a delicious vanilla flavour.

What all of these solutions have in common is that they’re JavaScript first. There’s nothing wrong with this, from an accessibility standpoint — it’s perfectly safe to assume that all the current speech and desktop browsers which are in use have JavaScript enabled. What’s more, these days, you need to go digging about in your browser’s guts, in order to disable it.

However, the cost of JavaScript is more than just the time it takes to download it. As the web becomes more and more dependent on JavaScript, the less we force the browser to parse, the better.

So it’s time to get CSS to do the job of JavaScript.

Sending CSS to do a script’s job

I appreciate this isn’t groundbreaking (the technique’s been around since at least 2016), but humour me. There’s some tasty nuggets in here.

The W3C take the approach of adding all of the ARIA markup directly into the HTML. The A11Y Project gets JavaScript to do that work. Let’s start by following the W3C implementation, because one of the aims of this is to offload work from the JavaScript rendering engine of the browser. Here’s where we are so far.

Pure CSS tabs by me on Codepen.

The flaws with this approach

There’s a lot wrong with this pure CSS implementation of tabs. Here’s the headlines:

  • You need to know in advance what the maximum number of tabs there will be on a page
  • You can only have one tab set on a page (you could have more, but you’d need to add more rules to the CSS to achieve this)
  • It doesn’t use the aria-selected attribute on the tabs or the hidden attribute on the tab panes (in order to dynamically update this, we’d need to use JavaScript)
  • The tab IDs are given boring names such as tab1 (we might want to use these IDs as URL fragments in the future)
  • If we add a role attribute onto either the label or the radio buttons, the page won’t validate any more* (page validation is part of WCAG)
  • You can navigate using the keyboard, but you need to use the cursor keys to do this (foreshadowing alert!)
  • Something something radio buttons were not designed to work like this

* The validater’s fine with a fieldset and input tags being outside of a form tag though.

Breakdown

Let’s go through how this works (if this isn’t your first rodeo, feel free to skip this part):

Screengrab indicating the markup for the tabs.
  • The whole tabset is wrapped in a fieldset tag. It seemed appropriate, as there’s form elements kicking about
  • Directly after the legend tag, there are five radio buttons (one for each tab). They have the following attributes:
  • Type radio
  • A name attribute in common, so that they all work in chorus with each other
  • An id, so they can be controlled by the label tags
  • An aria-controls attribute, which explicitly links them to one of the tab panes
  • A checked attribute (this is just on the first radio button, or whichever one you want to sit on top by default)
  • After that is a paragraph which contains five label tags, each of which toggles one of the five radio buttons on. Each of the label tags just has a for attribute (to link it explicitly with one of the radio buttons) and an id attribute, which I’ll cover in a moment.
  • After that are five section tags, where the tab content is. Each of those section tags has the following attributes:
  • A role attribute of tabpanel
  • An aria-labelledby attribute, which explicitly links the pane to the label tag above
  • An id attribute, which explicitly links the pane to the radio buttons
  • A tabindex attribute, which allows the tab to receive focus, as if it’s an anchor tag, or something

Interlinked

That’s some pretty crazy wiring — you can see that each element is connected to at least one other, like some Rube Goldberg machine. This is partially because we’re using label tags, but mostly because of ARIA. Here are the important connections:

Diagram of the markup connections between the radio buttons, the label tags and the tab pane.

CSS never targets the parent

The radio buttons appear pretty early in the markup. This is because of how CSS selectors work. You’re hopefully aware of this pattern:

body p {
margin: 0 0 1em;
}

If you were to translate this into English, it would read “find all of the paragraphs which are inside the body tag and take away all of the margin on them, apart from the bottom margin. That should be 1em”.

Likewise, there’s this pattern:

body > p {
margin: 0 0 1em;
}

That reads slightly differently: “Find all of the paragraphs which are an immediate child of the body, then take away the margins, but put a 1em margin on the bottom”.

Here’s a pattern you’ll never see:

p < div {
border: solid 1px black;
}

CSS rules always flow from parent to child (with a couple of exceptions, which I’ll cover in a minute). So you can’t dictate the styles of a parent element, just because you have access to the child. Hmm. This sounds a little like a divorce melodrama.

Poster for the film “Kramer vs. Kramer”, about a divorce.

The parent selector has been suggested by others, and shot down, on a regular basis. My guess is that this is to keep CSS parsing simple: browsers need only progress from the root element of the DOM down. This probably stops the browser from having to redraw the page from scratch, once it’s half way done and encounters a new rule.

Here’s the exceptions: since CSS2, we’ve had access to adjacent sibling selectors. These allow you to style elements which follow others, rather than are inside them. Here’s an example:

h1 + p:first-letter {
font-size: 2em;
float: left;
margin: 0 1em 1em 0;
}

The above example gives you one of those sexy “drop-cap” typography styles, but only on the first letter of a paragraph which immediately follows a heading level one. You can also use the subsequent sibling selector: ~. It works the same as the adjacent sibling selector, but will target all of the following siblings which match. You might use it like this:

input[name="disclaimer"]:focus ~ p {
animation-name: warning;
animation-duration: .5s;
animation-iteration-count: infinite;
}
@keyframes warning {
0 {color: #000;}
50% {color: #f00;}
100% {color: #000;}
}

This might make some scary disclaimer text throb a scary red colour, but only when you were considering checking a checkbox.

Diagram of the DOM tree, with arrows indicating which directions CSS selectors can flow.

What both these techniques rely on is the element which is to influence the change appearing first in the markup order. This is why the three checkboxes in the tabs example appear before the label tag.

The second exception to this rule is flexbox. It allows us to reorder the markup, to some degree, with the flex-direction and order properties. But let's not open that object of worms right now, eh?

Picking up some tabs from the shops

Screen grab of the tabs, from the CSS tab example.

Let’s move into the clickable tabs themselves. Usually, when we create a few elements which belong together, we put them in an unordered list. This gives us lots of markup to hook into and has a loose sort of semantics which suggests “here’s a few elements which belong together, in no particular order”. This is fine, but it’s overkill for what we need to build some tabs.

Too often, when we write markup, we unthinkingly put our content inside a div tag. Remember that divs are semantically neutral. But don’t just take my word for it:

Before: div. After: article, aside, fieldset, figure, footer, header, main, nav, section. Semantics! Try them today.

If you need to wrap a set of inline tags within a container, why not consider a paragraph?

  • Less to type
  • Suggests that the elements inside are content of some kind
  • A natural parent for inline tags
  • No need to cancel out all the list-style CSS on the parents of the label

The paragraph is linked to the legend tag of the fieldset, via an aria-labelledby attribute. That total legend really ties the fieldset together.

The tab panels

Screen grab of the first tab panel, from the CSS tab example.

Some of the other examples have the tab panels as divs. This is understandable - you should only use a section tag if you’re sure it will contain at least one heading (the markup won’t validate otherwise). However, I hate divs, so I’m going to use the section tag and insist that each tab pane has a heading inside it.

Each tab panel has a role attribute of tabpanel. It’s also got a tabindex of zero. This means that the tab panel itself can have focus. Weird.

We’ll come back to this later, when we talk about keyboard navigation.

Adding Microdata

Ah, Microdata, the ginger stepchild of web development. In my example tabs, it’s pretty clear what kind of data is presented. We usually don’t have that kind of luxury. Let’s have a look at how we could add Microdata anyway.

A tweaked version of the CSS tabs, but with Microdata added.

Visit schema.org and have a search around for data types which might be a good fit. If there’s an example, great! If it’s just the raw data structure, then this is going to be much harder. Don’t forget to validate your fragment of markup, to make sure it makes sense to The SEO Gods.

Keyboard navigation

The WAI ARIA Authoring Practices document has examples of what they call tabs with “automatic activation”. This means that as soon as the user presses the right or left arrow, the tab to the right or left will immediately open. If the user only uses the cursor keys, their focus is captured within the list of tabs.

If the user presses tab, the focus jumps from the tab to the tap panel.

Screen grab of the different ways to navigate through a tabset, using just the keyboard.

It was exactly this style of keyboard navigation which was tested by simply accessible, in 2016. They found that users who were used to navigating with the keyboard found this confusing. There’s a lot of lively and informed debate in the article and the comments (including criticism of the technique used in this very article), and I’ve personally no idea how to make a call on this issue. On one side, it seems obvious that methods of navigation should be clear to users. On the other, these methods evolve over time to meet new challenges and we cannot continue to use the same paradigm forever.

Sadly, most of my professional life, I’ve not had the luxury of usability testing to refine and polish the interfaces I build. Like most developers, I rely almost completely on the W3C to provide guidance of how I should build sites.

When is a tab not a tab?

This might seem a strange question, but in the example we have so far, what piece of markup represents the tab?

The obvious answer is the label tag. It’s the element which is styled by CSS to appear as a tab. In an earlier version of the tabs I built, I treated the label tags in exactly this way. But the radio buttons were still part of the tabindex and the labels were not. So I found myself doing this pattern:

<input type="radio" name="tabs" id="tab1" aria-controls="pane1" tabindex="-1">
<label for="tab1" id="click-tab1" tabindex="0">Groucho</label>

The code allowed the label tag to fall into focus and you could even click on it to change the selected radio button but it wasn’t possible to change the state of the radio button itself using the keyboard. So I wrote some JavaScript to do that. Then I wrote some more JavaScript, so that you could move forward and backward using the arrow keys. Then I took a long, hard look at my life and realised I had made a terrible mistake.

Hang on — the radio buttons were the tabs all along!

As soon as I started to consider the radio buttons as the tabs, lots of stuff started happening for free:

  • I could use the tab key to select the first tab
  • I could use the arrow keys to select the next or previous tab
  • The arrow keys even loop around
  • I could tab again to move further down the page

This left a few quality-of-life improvements:

  • The label tags need to reflect the current status of the radio buttons (this can be done in CSS)
  • End and Home should select the first and last tab, if the focus is inside the tabs (JavaScript)
  • The radio buttons should have dynamically updating ARIA-selected attributes (JavaScript)

Here’s the CSS which makes the label tag look selected, when the user puts the hidden radio button into focus:

input[id="tab1"]:focus ~ p [for="tab1"] {
outline: dotted .1em #000;
/* Oooh, get you! */
outline-offset: -.3em;
}

This selector translates into English as “you know the first radio button? When that’s in focus, find the following element with a role="tablist", then find the element inside that which has a for attribute of tab1”.

Unfortunately, you’d need an additional selector for each tab on the page. But I’ve built the SCSS so that it loops, to save you some bother.

SCSS / CSS

So one of the limitations of this approach is that every tab on the page needs three different selectors:

  1. A selector to show the tab panel, when the right radio button is shown
  2. A selector which makes the checked label tab all big, so it looks like it’s in the foreground
  3. A selector which reflects that the hidden radio button is in focus, on the label tag it’s attached to (that’s the rule I showed you just a moment ago)

SCSS loops seemed a pretty good fit for this. Here’s how I started:

$tab-count: 5;
@for $i from 1 through $tab-count {
// Showing the current tab pane
input[id="tab#{$i}"]:checked ~ [id="pane#{$i}"] {
display: block;
}
// Selected tab
input[id="tab#{$i}"]:checked ~ p [for="tab#{$i}"] {
font-size: 1.2em;
}
// Focused tab
// (this is the clever bit: the label gets a style when the hidden radio button goes into focus)
input[id="tab#{$i}"]:focus ~ p [for="tab#{$i}"] {
outline: dotted .1em #000;
}
}

For the above:

  • $i is the counter which increments with each cycle of the loop.
  • $tab-count is the maximum number of tabs we need on any given page of the site as a whole.

Because I’m always calling $i inside quotation marks (for example [for="tab1"]), we need to use the “interpolation syntax”. The problem with this SCSS is that the loop will happen (say) five times, and those three rules will mean that fifteen rules in total will be written to the final CSS. This is not efficient!

The answer is to take all of the declarations within those rules and store them in an @extend property:

%show-tab {
display: block;
}
%selected-tab {
font-size: 1.2em;
}
%focused-tab {
outline: dotted .1em #000;
}

… then call them from within the loop like this:

$tab-count: 5;
@for $i from 1 through $tab-count {
// Showing the current tab pane
input[id="tab#{$i}"]:checked ~ [id="pane#{$i}"] {
@extend %show-tab;
}
// Selected tab
input[id="tab#{$i}"]:checked ~ p [for="tab#{$i}"] {
@extend %selected-tab;
}
// Focused tab
input[id="tab#{$i}"]:focus ~ p [for="tab#{$i}"] {
@extend %focused-tab;
}
}

This generates no more than three CSS rules, with multiple selectors on each.

JavaScript

So we need JavaScript to do two things:

  1. Move the aria-selected attribute to the currently selected radio button
  2. Allow users to get to the first and last tab by using the Home and End keys on the keyboard

I’ve created a new version of the tabs, to demonstrate this (yes, this is “tabs without JavaScript, now with added JavaScript”). Note that this JavaScript is written on the understanding that there will only be one tab set on a page. This might not be the case, in the so-called real world (the fix is probably to put all the JavaScript into a function and call that function for each set of tabs on the page).

Tabs without JavaScript, now with added JavaScript.

The first job is achieved simply enough: wait for the user to interact with the tabs then use document.querySelectorAll to loop through all the tab radio buttons on the page, set them to aria-selected="false", then set the current radio button to true.

The second part loops through the radio buttons again (strictly speaking, we use the same loop, but whatever), attaching event listeners willy-nilly to those radio buttons. What we’re looking for is the user doing a keydown, while their attention is one one of them.

Next, you’ll notice a bit of code conspicuous by its absence from the WAI ARIA Practices example:

var oldKey = e.keyCode,
newKey = e.key;

See, we’re not supposed to use keyCode any more, because the numbers we associate with particular keys don’t match up with everyone’s keyboard. Unfortunately, support for key isn’t as good as it should be. So let’s take a belt-and-braces approach. Because conditions check from left to right, let’s include both in the if statement, and hope one turns up (if you need to perform this check more than once, it would be better to use a global variable).

So for the if statement for the home key becomes:

if (newKey === 'Home' || oldKey === 36) {
e.preventDefault();
var radio1 = tabRadios.item(0);
radio1.focus();
radio1.click();
}

So originally at this point, this article said this:

That preventDefault is suppose to stop the default behaviour of the Home key (which is to send the page scrolling to the top). This is the bit of code which does absolutely nothing, and I've no idea why. The W3C approach is basically identical (except they don't use radio buttons and labels). Other than scroll-jacking, I've no idea how to fix this, so I'll happily be corrected by someone cleverer than me.

(I wanted to use the s tag to strike that paragraph out, but Medium doesn’t support s, del or strike (depreciated) unfortunately)

I presented this article as a talk to my team and I am indebted to Kevin Falencik for spotting the bug. He realised that my JavaScript was running onkeyup, rather than keydown, leaving the browser plenty of time to act, before my e.preventDefault() ran. Thanks, Kevin. Thevin.

Unfortunately, the functions focus() and click() don’t chain, because this ain’t jQuery. So they’re called one after the other. Focus and selected are two different states on radio buttons, but it’s only possible to move focus through a set of radio buttons before one of them has a checked state. Before this, the unchecked radio buttons get an outline and the tab key moves through each in sequence.

However, as soon as one radio button is selected, they become one unit: tabbing simply allows the cursor to enter and enter the set. Once any radio button of a set is selected, the only way to move the checked state from one to another is to use the cursor keys (right or down selects the next radio button in the markup, left or up selects the previous radio button).

Because of this ambiguity between focus and checked states, I decided to push both focus and click onto the radio button, whenever the user presses Home or End.

--

--

Ross Angus

The views I express here are mine alone and do not necessarily reflect the views of my employer.