Tabs, done right

There are a lot of JavaScript-driven tab widgets around the web. All of the major JS libraries provide a tabs widget, and this is such a common user interface pattern that the ARIA specification includes several roles (tab, tablist, tabpanel) to allow accessible description of the widget. This is a good thing, but there is a problem. All of these implementations (including the ARIA example) use the following type of markup to define tab widgets:

<div class="tabs">
    <ul>
        <li><a href="#panel1">Tab 1</a></li>
        <li><a href="#panel2">Tab 2</a></li>
        <li><a href="#panel3">Tab 3</a></li>
    </ul>
    <div id="panel1">
        <!-- panel content -->
    </div>
    <div id="panel2">
        <!-- panel content -->
    </div>
    <div id="panel3">
        <!-- panel content -->
     </div>
</div>

What's wrong with that? Well, the semantics are a bit off. Granted, it's not the worse example of content structure, but it's not very user friendly either: you have a list of links to content (the tabs), followed by several essentially unstructured content snippets (the panels). The tabs usually hold a meaningful title for the respective panel content, which makes sense visually since the tab and the content are rendered closely together and with visual clues indicating their relatedness once the JavaScript kicks in.

However, the tabs are not usually marked up as content headers (say, <h*>) since that would not create a meaningful content outline (all the headers would grouped together).  Also, if you wanted to use the same markup to provide different rendering depending on context you'd be in a bit of trouble. Here are just a couple of examples where you might want to do this:

  • When printing the document tabs are obviously meaningless, so we'd want to output all panel sections with the correct title above each one.
  • On a touch-screen smartphone we'd want to use an accordion widget instead of tabs.
  • A screen reader could avoid the whole tabbed interface nastyness

The essential problem is the disassociation between title and content: the markup has been written to suit a specific UI widget. It is possible to work around this by adding the title to each panel and selectively hiding/showing it depending on context, but that is not an elegant solution.

Doing it right

That is all very interesting, you say, but where is the elegant solution, then? Coming right up. We start with some simple, semantic markup:

<h3>One</h3>
<p>Lorem ipsum dolor sit amet.</p>
<p>Proin posuere, velit non facilisis rutrum.</p>

<h3>Two</h3>
<p>Quisque tempor ligula risus.</p>

<h3>Three</h3>
<p>Suspendisse a orci turpis.</p>

And we add some scaffolding (note the selected class to highlight the selected tab):

<div class="tabWidget">
    <div class="tabWidget">
	<div class="tabPanel">
		<h3 class="tab">One</h3>
		<div class="panel">
			<div class="panel-inner">
				<p>Lorem ipsum dolor sit amet.</p>
				<p>Proin posuere, velit non facilisis rutrum.</p>
			</div>
		</div>
	</div>
	<div class="tabPanel selected">
		<h3 class="tab">Two</h3>
		<div class="panel">
			<div class="panel-inner">
				<p>Quisque tempor ligula risus.</p>
			</div>
		</div>
	</div>
	<div class="tabPanel">
		<h3 class="tab">Three</h3>
		<div class="panel">
			<div class="panel-inner">
				<p>Suspendisse a orci turpis.</p>
			</div>
		</div>
	</div>
    </div>
</div>

Note that I am using <div>s simply to keep the original semantics. You can use whatever elements make sense — there is an example using a list on the demo page. Here is the CSS:

.tabWidget {
    overflow: hidden;
}

.tabWidget .tabPanel {
    display: block;
}

.tabWidget .tab {
    float: left;
    /* change rules below for your desired visual effect */
    cursor: pointer;
    border: 2px solid #000;
    margin-right: 5px;
    padding: 2px 8px;
}

.tabWidget .panel {
    display: none;
    float: right;
    margin-left: -100%;
    margin-top: 1.7em;  /* adjust as needed */
    width: 100%;
}

.tabWidget .panel-inner {
    /* change rules below for your desired visual effect */
    border: 2px solid #000;
    padding: 10px;
}

.tabWidget .selected .tab {
    /* change rules below for your desired visual effect */
    background-color: #000;
    color: #FFF;
}

.tabWidget .selected .panel {
    display: block;
}

The magic happens in the rules float: right, margin-left: -100%, and width: 100%, applied to the .panel <div>. These rules remove the panels from the horizontal flow of the document (since their width is cancelled by their negative margin), but leave their vertical dimension intact, allowing them to push down the bottom of the tab widget. A gotcha is that the .panel <div>s must be pushed down by the correct amount so that they line up with the bottom of the tabs (i.e. adjust the margin-top: 1.7em rule to suit your design). This also means that multi-line tabs are probably difficult to achieve (I haven't tried them; let me know if you have ideas). The purpose of the .panel-inner <div> is to allow for borders and/or padding to be added to the panel content without interfering with the width: 100% rule applied to .panel <div>. If you need neither, these can be removed. All we need now is some JavaScript to handle the tab switching. Here's an example using JQuery:

$.fn.tabsDoneRight = function(options) {

    return this.each(function(index, el) {

        $(el).find(".tab").each(function(tabIndex, tabEl) {

            $(tabEl).click(function() {

                $(el).find(".selected").removeClass("selected");
                $(this).parents(".tabPanel").addClass("selected");

                return false;
            });

            $(tabEl).attr("tabindex", 0).keypress(function(ev) {

                if (ev.keyCode == 13) $(this).click();
            });
        });

        if (!$(el).find(".tabPanel:has(.selected)").length) {

            $(el).find(".tabPanel").first().click();
        }
    });
};

$(function() {

    $(".tabWidget").tabsDoneRight();
});

More accessible

We're adding onclick behaviour to elements that don't have a default interaction (the <h3> tabs) so keyboard navigation won't automatically be added by the browser. We must make the tabs focusable by the tab key (setting the tabindex attribute), and triggering the click event handler when the enter key is pressed (see the keypress event handler in the code above). What if JavaScript is disabled? The display: none rule will keep all non-selected tab panels inaccessible, so I recommend a little trick: display all panels by default, and trigger the hiding if JS is enabled. Let's change the CSS:

.tabWidget {
    overflow: hidden;
}

.tabWidget .tabPanel {
    display: block;
}

.tabWidget .tab {
    clear: both;
    float: left;
    /* change rules below for your desired visual effect */
    cursor: pointer;
    border: 2px solid #000;
    margin-right: 5px;
    padding: 2px 8px;
}

.js .tabWidget .tab {
    clear: none;
}

.tabWidget .panel {
    clear: right;
    display: block;
    float: right;
    margin-left: -100%;
    margin-top: 1.7em;  /* adjust as needed */
    width: 100%;
}

.js .tabWidget .panel {
    display: none;
    clear: none;
}

.tabWidget .panel-inner {
    /* change rules below for your desired visual effect */
    border: 2px solid #000;
    padding: 10px;
}

.tabWidget .selected .tab {
    /* change rules below for your desired visual effect */
    background-color: #000;
    color: #FFF;
}

.js .tabWidget .selected .panel {
    display: block;
}

To trigger the correct CSS for JS-enabled pages, add this code as far up in the <body> as possible:

<script type="text/javascript">
document.body.className += " js";
</script>

Putting it all together

With the above markup it should now be relatively straightforward to create alternative styling for mobile and print mediums. Even better, making use of CSS media queries you can decide to change the appearance and behaviour of the widget based, for instance, on the available viewport space. This solution has been tested in IE6, IE7, IE8, FF3.6, Opera 10, Chrome 6. It also seems to work in mobile browsers (Nokia and Sony Ericsson), although you will need to make the tabs either links or buttons (maybe via the JQuery wrap() function?) since the onclick event doesn't seem to trigger on non-link elements on those browsers..

8 thoughts on “Tabs, done right

  1. Richard

    Hey! Well I’m using your tabs in my own project now, looks and works great!

    I was wondering if you thought about using a hash (named anchor) on the url so that the jQuery function knows which tab to select on page load? i.e. page.aspx#tab=1

    I’ll be converting this into a lovely asp.net control in due course and although it will be fine to control it server-side, it might be nice for others when they don’t have that ability.

    Reply
  2. Richard

    Thinking about this a bit more, I’m not actually sure that using hashes would have the required effect since when you post-back in ASP.NET, the hash gets dropped (since everything after the hash is not sent to the server in the http request).

    If this is going to work as a .NET control, it’s probably going to require some hidden-field to track the state. Not the most elegant of programming, but it will work.

    I had to do something similar for a server-side jQuery dialog control (where the result of a post-back will conjure a dialog). I needed to store the fact that the user had dismissed the dialog client-side so that the server-side code wouldn’t just keep popping it back up after a post-back!

    Must get round to blogging about that control!

    Reply
  3. Nelson

    Why would you need server-side tracking of tab state? The hash is read client-side only by the history manager plugin and the tab state is restored on page load. The good thing with the markup model is that the bookmark would work even if JS was off and there were no tabs (scroll to ID in page).

    Reply
  4. Richard

    Hey!

    Been using this for a while now and thought of a couple of improvements that should make it a really useful widget:

    + Use the ‘live’ or ‘delegate’ event instead of binding the click events on the tabs: this will mean that you can dynamically drop in a complete content replacement for any given tab without manually re-binding the events (e.g. using $(“#tabPanel1”).load(“/my/tab/contents);)

    + Provide support for nested tab panels (currently the click event binds to all ‘.tab’ elements under the tabPanel and not just the direct children)

    John: personally I’m not convinced that the semantics of a definition list fits correctly here, dt meaning ‘definition title’; it’s really meant for defining phrases or words

    I reckon this should become the norm for jQuery UI – have you dropped them a line?

    Reply
  5. Steve M

    Yes!!! Thanks! I have to develop some stuff with IE6, and this was the only example I could find on the internet that worked. Thanks heaps! πŸ™‚ πŸ™‚

    Reply
  6. Andy D

    could the tabs be made into a right sidebar or does it require the nav be on top or on bottom?
    I’d like to do this with the nav stacked on the right hand side so I’m guessing I’d need to change the li display to block and float right?

    Reply

Leave a Reply to Richard Cancel reply