The tabs adventure
Tabs are a solved problem, right? WRONG.
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.
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 thehidden
attribute on the tab panes (in order to dynamically update this, we’d need to use JavaScript) - The tab
ID
s are given boring names such astab1
(we might want to use these IDs as URL fragments in the future) - If we add a
role
attribute onto either thelabel
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):
- 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 thelabel
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 thelabel
tags just has afor
attribute (to link it explicitly with one of the radio buttons) and anid
attribute, which I’ll cover in a moment. - After that are five
section
tags, where the tab content is. Each of thosesection
tags has the following attributes: - A
role
attribute oftabpanel
- An
aria-labelledby
attribute, which explicitly links the pane to thelabel
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:
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.
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.
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
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 div
s are semantically neutral. But don’t just take my word for it:
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 thelabel
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
Some of the other examples have the tab panels as div
s. 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 div
s, 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.
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.
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:
- A selector to show the tab panel, when the right radio button is shown
- A selector which makes the checked
label
tab all big, so it looks like it’s in the foreground - 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:
- Move the
aria-selected
attribute to the currently selected radio button - 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).
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.