Skip to content

24 ways to impress your friends

How Tabs Should Work

Tabs in browsers (not browser tabs) are one of the oldest custom UI elements in a browser that I can think of. They’ve been done to death. But, sadly, most of the time I come across them, the tabs have been badly, or rather partially, implemented.

So this post is my definition of how a tabbing system should work, and one approach of implementing that.

But… tabs are easy, right?

I’ve been writing code for tabbing systems in JavaScript for coming up on a decade, and at one point I was pretty proud of how small I could make the JavaScript for the tabbing system:

var tabs = $('.tab').click(function () {
  tabs.hide().filter(this.hash).show();
}).map(function () {
  return $(this.hash)[0];
});

$('.tab:first').click();

Simple, right? Nearly fits in a tweet (ignoring the whole jQuery library…). Still, it’s riddled with problems that make it a far from perfect solution.

Requirements: what makes the perfect tab?

  1. All content is navigable and available without JavaScript (crawler-compatible and low JS-compatible).
  2. ARIA roles.
  3. The tabs are anchor links that:
    • are clickable
    • have block layout
    • have their href pointing to the id of the panel element
    • use the correct cursor (i.e. cursor: pointer).
  4. Since tabs are clickable, the user can open in a new tab/window and the page correctly loads with the correct tab open.
  5. Right-clicking (and Shift-clicking) doesn’t cause the tab to be selected.
  6. Native browser Back/Forward button correctly changes the state of the selected tab (think about it working exactly as if there were no JavaScript in place).

The first three points are all to do with the semantics of the markup and how the markup has been styled. I think it’s easy to do a good job by thinking of tabs as links, and not as some part of an application. Links are navigable, and they should work the same way other links on the page work.

The last three points are JavaScript problems. Let’s investigate that.

The shitmus test

Like a litmus test, here’s a couple of quick ways you can tell if a tabbing system is poorly implemented:

  • Change tab, then use the Back button (or keyboard shortcut) and it breaks
  • The tab isn’t a link, so you can’t open it in a new tab

These two basic things are, to me, the bare minimum that a tabbing system should have.

Why is this important?

The people who push their so-called native apps on users can’t have more reasons why the web sucks. If something as basic as a tab doesn’t work, obviously there’s more ammo to push a closed native app or platform on your users.

If you’re going to be a web developer, one of your responsibilities is to maintain established interactivity paradigms. This doesn’t mean don’t innovate. But it does mean: stop fucking up my scrolling experience with your poorly executed scroll effects. </rant> :breath:

URI fragment, absolute URL or query string?

A URI fragment (AKA the # hash bit) would be using mysite.com/config#content to show the content panel. A fully addressable URL would be mysite.com/config/content. Using a query string (by way of filtering the page): mysite.com/config?tab=content.

This decision really depends on the context of your tabbing system. For something like GitHub’s tabs to view a pull request, it makes sense that the full URL changes.

For our problem though, I want to solve the issue when the page doesn’t do a full URL update; that is, your regular run-of-the-mill tabbing system.

I used to be from the school of using the hash to show the correct tab, but I’ve recently been exploring whether the query string can be used. The biggest reason is that multiple hashes don’t work, and comma-separated hash fragments don’t make any sense to control multiple tabs (since it doesn’t actually link to anything).

For this article, I’ll keep focused on using a single tabbing system and a hash on the URL to control the tabs.

Markup

I’m going to assume subcontent, so my markup would look like this (yes, this is a cat demo…):

<ul class="tabs">
  <li><a class="tab" href="#dizzy">Dizzy</a></li>
  <li><a class="tab" href="#ninja">Ninja</a></li>
  <li><a class="tab" href="#missy">Missy</a></li>
</ul>

<div id="dizzy">
  <!-- panel content -->
</div>
<div id="ninja">
  <!-- panel content -->
</div>
<div id="missy">
  <!-- panel content -->
</div>

It’s important to note that in the markup the link used for an individual tab references its panel content using the hash, pointing to the id on the panel. This will allow our content to connect up without JavaScript and give us a bunch of features for free, which we’ll see once we’re on to writing the code.

URL-driven tabbing systems

Instead of making the code responsive to the user’s input, we’re going to exclusively use the browser URL and the hashchange event on the window to drive this tabbing system. This way we get Back button support for free.

With that in mind, let’s start building up our code. I’ll assume we have the jQuery library, but I’ve also provided the full code working without a library (vanilla, if you will), but it depends on relatively new (polyfillable) tech like classList and dataset (which generally have IE10 and all other browser support).

Note that I’ll start with the simplest solution, and I’ll refactor the code as I go along, like in places where I keep calling jQuery selectors.

function show(id) {
  // remove the selected class from the tabs,
  // and add it back to the one the user selected
  $('.tab').removeClass('selected').filter(function () {
    return (this.hash === id);
  }).addClass('selected');

  // now hide all the panels, then filter to
  // the one we're interested in, and show it
  $('.panel').hide().filter(id).show();
}

$(window).on('hashchange', function () {
  show(location.hash);
});

// initialise by showing the first panel
show('#dizzy');

This works pretty well for such little code. Notice that we don’t have any click handlers for the user and the Back button works right out of the box.

However, there’s a number of problems we need to fix:

  1. The initialised tab is hard-coded to the first panel, rather than what’s on the URL.
  2. If there’s no hash on the URL, all the panels are hidden (and thus broken).
  3. If you scroll to the bottom of the example, you’ll find a “top” link; clicking that will break our tabbing system.
  4. I’ve purposely made the page long, so that when you click on a tab, you’ll see the page scrolls to the top of the tab. Not a huge deal, but a bit annoying.

From our criteria at the start of this post, we’ve already solved items 4 and 5. Not a terrible start. Let’s solve items 1 through 3 next.

Using the URL to initialise correctly and protect from breakage

Instead of arbitrarily picking the first panel from our collection, the code should read the current location.hash and use that if it’s available.

The problem is: what if the hash on the URL isn’t actually for a tab?

The solution here is that we need to cache a list of known panel IDs. In fact, well-written DOM scripting won’t continuously search the DOM for nodes. That is, when the show function kept calling $('.tab').each(...) it was wasteful. The result of $('.tab') should be cached.

So now the code will collect all the tabs, then find the related panels from those tabs, and we’ll use that list to double the values we give the show function (during initialisation, for instance).

// collect all the tabs
var tabs = $('.tab');

// get an array of the panel ids (from the anchor hash)
var targets = tabs.map(function () {
  return this.hash;
}).get();

// use those ids to get a jQuery collection of panels
var panels = $(targets.join(','));

function show(id) {
  // if no value was given, let's take the first panel
  if (!id) {
    id = targets[0];
  }
  // remove the selected class from the tabs,
  // and add it back to the one the user selected
  tabs.removeClass('selected').filter(function () {
    return (this.hash === id);
  }).addClass('selected');

  // now hide all the panels, then filter to
  // the one we're interested in, and show it
  panels.hide().filter(id).show();
}

$(window).on('hashchange', function () {
  var hash = location.hash;
  if (targets.indexOf(hash) !== -1) {
    show(hash);
  }
});

// initialise
show(targets.indexOf(location.hash) !== -1 ? location.hash : '');

The core of working out which tab to initialise with is solved in that last line: is there a location.hash? Is it in our list of valid targets (panels)? If so, select that tab.

The second breakage we saw in the original demo was that clicking the “top” link would break our tabs. This was due to the hashchange event firing and the code didn’t validate the hash that was passed. Now this happens, the panels don’t break.

So far we’ve got a tabbing system that:

  • Works without JavaScript.
  • Supports right-click and Shift-click (and doesn’t select in these cases).
  • Loads the correct panel if you start with a hash.
  • Supports native browser navigation.
  • Supports the keyboard.

The only annoying problem we have now is that the page jumps when a tab is selected. That’s due to the browser following the default behaviour of an internal link on the page. To solve this, things are going to get a little hairy, but it’s all for a good cause.

Removing the jump to tab

You’d be forgiven for thinking you just need to hook a click handler and return false. It’s what I started with. Only that’s not the solution. If we add the click handler, it breaks all the right-click and Shift-click support.

There may be another way to solve this, but what follows is the way I found – and it works. It’s just a bit… hairy, as I said.

We’re going to strip the id attribute off the target panel when the user tries to navigate to it, and then put it back on once the show code starts to run. This change will mean the browser has nowhere to navigate to for that moment, and won’t jump the page.

The change involves the following:

  1. Add a click handle that removes the id from the target panel, and cache this in a target variable that we’ll use later in hashchange (see point 4).
  2. In the same click handler, set the location.hash to the current link’s hash. This is important because it forces a hashchange event regardless of whether the URL actually changed, which prevents the tabs breaking (try it yourself by removing this line).
  3. For each panel, put a backup copy of the id attribute in a data property (I’ve called it old-id).
  4. When the hashchange event fires, if we have a target value, let’s put the id back on the panel.

These changes result in this final code:

/*global $*/

// a temp value to cache *what* we're about to show
var target = null;

// collect all the tabs
var tabs = $('.tab').on('click', function () {
  target = $(this.hash).removeAttr('id');

  // if the URL isn't going to change, then hashchange
  // event doesn't fire, so we trigger the update manually
  if (location.hash === this.hash) {
    // but this has to happen after the DOM update has
    // completed, so we wrap it in a setTimeout 0
    setTimeout(update, 0);
  }
});

// get an array of the panel ids (from the anchor hash)
var targets = tabs.map(function () {
  return this.hash;
}).get();

// use those ids to get a jQuery collection of panels
var panels = $(targets.join(',')).each(function () {
  // keep a copy of what the original el.id was
  $(this).data('old-id', this.id);
});

function update() {
  if (target) {
    target.attr('id', target.data('old-id'));
    target = null;
  }

  var hash = window.location.hash;
  if (targets.indexOf(hash) !== -1) {
    show(hash);
  }
}

function show(id) {
  // if no value was given, let's take the first panel
  if (!id) {
    id = targets[0];
  }
  // remove the selected class from the tabs,
  // and add it back to the one the user selected
  tabs.removeClass('selected').filter(function () {
    return (this.hash === id);
  }).addClass('selected');

  // now hide all the panels, then filter to
  // the one we're interested in, and show it
  panels.hide().filter(id).show();
}

$(window).on('hashchange', update);

// initialise
if (targets.indexOf(window.location.hash) !== -1) {
  update();
} else {
  show();
}

This version now meets all the criteria I mentioned in my original list, except for the ARIA roles and accessibility. Getting this support is actually very cheap to add.

ARIA roles

This article on ARIA tabs made it very easy to get the tabbing system working as I wanted.

The tasks were simple:

  1. Add aria-role set to tab for the tabs, and tabpanel for the panels.
  2. Set aria-controls on the tabs to point to their related panel (by id).
  3. I use JavaScript to add tabindex=0 to all the tab elements.
  4. When I add the selected class to the tab, I also set aria-selected to true and, inversely, when I remove the selected class I set aria-selected to false.
  5. When I hide the panels I add aria-hidden=true, and when I show the specific panel I set aria-hidden=false.

And that’s it. Very small changes to get full sign-off that the tabbing system is bulletproof and accessible.

Check out the final version (and the non-jQuery version as promised).

In conclusion

There’s a lot of tab implementations out there, but there’s an equal amount that break the browsing paradigm and the simple linkability of content. Clearly there’s a special hell for those tab systems that don’t even use links, but I think it’s clear that even in something that’s relatively simple, it’s the small details that make or break the user experience.

Obviously there are corners I’ve not explored, like when there’s more than one set of tabs on a page, and equally whether you should deliver the initial markup with the correct tab selected. I think the answer lies in using query strings in combination with hashes on the URL, but maybe that’s for another year!

About the author

Remy Sharp is the founder and curator of Full Frontal, the UK based JavaScript conference. He also ran jQuery for Designers, co-authored Introducing HTML5 (adding all the JavaScripty bits) and likes to grumble on Twitter.

Whilst he’s not writing articles or running and speaking at conferences, he runs his own development and training company in Brighton called Left Logic. And he built these too: Confwall, jsbin.com, html5demos.com, remote-tilt.com, responsivepx.com, nodemon, inliner, mit-license.org, snapbird.org, 5 minute fork and jsconsole.com!

More articles by Remy

Comments