Transformer Tabs

Published by Chris Coyier

Tabs are a simple design pattern in which a row of links are obviously clickable navigation and when a link is clicked, new content is shown. There are loads of variations of course, but it's one of the most ubiquitous navigation design patterns out there. When arranged in a horizontal row, it is also one of the least small-screen-friendly design patterns out there.

We can make it work though.

Without doing anything at all to help tabs on a small screen, you'll run into some kind of problem

  • You let the page be "zoomed out" and the tabs are tiny tap targets
  • You let the page be device-width and...
    • there isn't room for the tabs so they cut off.
    • the tabs wrap and look weird and take up too much space.

Basically: we need to do something here to make tabs better for small screens.

This Has Been Done Before

Popular solutions include:

I'd like to do it again with some specific goals.

Goals

Here's the plan:

  • Normal "tabs" look when there is room, dropdown when there isn't.
  • "Current" tab always shown and obvious.
  • Works with #hash tabs where all the content is on the page and content panels are hidden/shown.
  • Works with linked tabs where the tabs link to a different URL.
  • The HTML is semantic.
  • There is one version of the HTML that doesn't change.
  • There is one version of the JavaScript that doesn't change.
  • You can link to a particular tab.

So we're not just tackling the design but the functionality.

Patterns like the "off canvas" style won't work here as we're trying to show the current tab, not hide it. Converting in a <select> dropdown won't work because that's different HTML and different JavaScript.

The HTML

Tabs are navigation, so <nav>. The role should be implied by the tag, but you can't always count on that, thus we add the role. We use a class for the CSS on the outermost element (the <nav>). The navigation items themselves are in a list because that's best. Each link has either a #hash target or a valid URL.

<nav role='navigation' class="transformer-tabs">
    <ul>
      <li><a href="#tab-1">Important Tab</a></li>
      <li><a href="#tab-2" class="active">Smurfvision</a></li>
      <li><a href="#tab-3">Monster Truck Rally</a></li>
      <li><a href="http://google.com">Go To Google &rarr;</a></li>
    </ul>
</nav>

Nothing superfluous.

The Tabbed View CSS

There is a set of specific CSS for the tabs in each "state" (tabs or dropdown). You can start "mobile first" by styling the dropdown then using min-width media query to re-arrange to look tabbed or you can start "desktop first" by styling the tabs first then using a max-width media query to re-arrange into a dropdown. They are so different I don't see any big advantage either way, but I'd match what you are doing elsewhere on the site.

Desktop first and SCSS for this demo.

.transformer-tabs {
  ul {
    list-style: none;
    padding: 0;
    margin: 0;
    border-bottom: 3px solid white;
  }
  li {
    display: inline-block;
    padding: 0;
    vertical-align: bottom;
  }
  a {
    display: inline-block;
    color: white;
    text-decoration: none;
    padding: 0.5rem;
    &.active {
      border-bottom: 3px solid black;
      position: relative;
      bottom: -3px;
    }
  }
}

Just a row of links with a line beneath it. The anchor link with an "active" class gets a different color border that overlaps the edge-to-edge border. vertical-align keeps them all on the same baseline when the active link gets the border the others don't have.

The Dropdown View CSS

We need to find a viewport width in which the tabbed look breaks down and put a media query there. 700px for this demo.

.transformer-tabs {
  ...
  @media (max-width: 700px) {
    ul {
      border-bottom: 0;
      overflow: hidden;
      position: relative;
      background: linear-gradient(#666, #222);
      &::after {
        content: "☰"; /* "Three Line Menu Navicon" shows up */
        position: absolute;
        top: 8px;
        right: 15px;
        z-index: 2;
        pointer-events: none;
      }
    }
    li {
      display: block; /* One link per "row" */
    }
    a {
      position: absolute; /* Stack links on top of each other */
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      &.active {
        border: 0;
        z-index: 1; /* Active tab is on top */
        background: linear-gradient(#666, #222);
      }
    }
  }
}

When the media query hits, this gets us here:

The active tab is still obvious (it's the only one shown) and a three line menu navicon is shown, which is getting universally known as a symbol to reveal more navigation.

The JavaScript Structure

The functionality we need:

  • If it's a #hash link, selecting it reveals the panel with that ID and hides the currently shown one.
  • Change the URL to indicate that #hash, but don't affect history (no back-button annoyances).
  • Visually indicate the now-currently-active tab (toggle classes appropriately).
  • If it's a linked tab, allow that link to work.
  • If the page loads with a hash that matches a tab, go to that tab.
  • In the dropdown state for small screens, allow the dropdown to open/close when you select it.

Some structure based on those requirements:

var Tabs = {

  init: function() {
    this.bindUIfunctions();
    this.pageLoadCorrectTab();
  },

  bindUIfunctions: function() {
  },

  changeTab: function(hash) {
  },

  pageLoadCorrectTab: function() {
  },

  toggleMobileMenu: function(event, el) {
  }

}

Tabs.init();

Changing Tabs Upon Selection

The only time we need to change a tab on selection is when the selected tab is a #hash link. Otherwise it's a linked tab and it should just follow that link. (And by "selection" I mean clicked or tapped or tabbed to and activated or whatever.) Thus our changeTab function can just accept that hash value and use it.

  changeTab: function(hash) {
    
    // find the link based on that hash
    var anchor = $("[href=" + hash + "]");

    // find the related content panel
    var div = $(hash);

    // activate correct anchor (visually)
    anchor.addClass("active").parent().siblings().find("a").removeClass("active");

    // activate correct div (visually)
    div.addClass("active").siblings().removeClass("active");

    // update URL, no history addition
    window.history.replaceState("", "", hash);

    // Close menu, in case in dropdown state
    anchor.closest("ul").removeClass("open");

  },

Handling Tab Clicks

We'll use standard event delegation here just to be efficient. Any "click" (which works fine for taps), assuming it's not the already-active tab and it's a #hash link, will just pass along that hash to the the changeTab function.

    // Delegation
    $(document)
      .on("click", ".transformer-tabs a[href^='#']:not('.active')", function(event) {
        Tabs.changeTab(this.hash);
        event.preventDefault();
      })

... and preventDefault() so the page doesn't jump down awkwardly.

Toggling the Dropdown

If the media query is in effect and thus the tabs in their dropdown state, we can toggle the dropdown "open" and "closed" when the .active tab is clicked. Because of our styling, we know the active tab covers the entire clickable area.

    $(document)
      // ... first click handler, chaining for efficiency 
      .on("click", ".transformer-tabs a.active", function(event) {
        Tabs.toggleMobileMenu(event, this);
        event.preventDefault();
      });

The toggleMobileMenu function is very simple. But I still like that we've abstract it into it's own function in case some day it needs to do more, we aren't getting all spaghetti'd up.

  toggleMobileMenu: function(event, el) {
    $(el).closest("ul").toggleClass("open");
  }

The .open class visually opens the menu via some CSS changes.

.transformer-tabs {
  ...
  @media (max-width: 700px) {
    ul {
      ...
      &.open {
        a {
          position: relative;
          display: block;
        }
      }
    }
  }
  ...
}

Removing the absolute positioning on those tabs and making them block-level makes the menu expand down and push the content below down as well. This is what makes it a dropdown.

Load the Correct Tab When Page is Loaded with a #hash

Turns out this is super easy. Just look at the hash in the URL and pass that to the changeTab function.

  pageLoadCorrectTab: function() {
    this.changeTab(document.location.hash);
  },

This is why we abstract functionality into functions we can re-use instead of spaghetti-land.

This Isn't Just Theoretical

I'm writing this after fixing up the tabs on CodePen, where the tabs kinda sucked until I did this. We have both #hash link tabs and real linked tabs on CodePen, thus the requirements.

Wanna Make It Better?

Perhaps a version where the menu doesn't push the content below down, the expanded dropdown just sits on top of the content would be cool. Perhaps one that could handle a ton of links (more than a small screen's worth) with some kind of scrolling or pagination. Perhaps one with some animations/transitions.

One issue with the exact demo we've built here is that you can't close the dropdown unless you select a tab. You can select the current tab to close it without doing anything, but you can't just click the three-line menu to close it. That's because you can't (as far as I know) bind a click event to a pseudo element. On CodePen I just used a <span> for the three-line menu so you could toggle it that way, but that does mean additional markup.

Demo

Note: The "Go To Google →" is a linked tab just to test it. It doesn't work here on CodePen because of the sandboxed iframe. It would work under normal circumstances.

See the Pen Transformer Tabs by Chris Coyier (@chriscoyier) on CodePen