Solution: Tabs

Front-End Challenges Club - Solution #005

This is the solution for Challenge #005.

Tabs and I don’t get along. I seriously dislike them, but I also understand their popularity, so today, we’re going to do them right.

We’re going to really dive into some progressive enhancement with this solution and because of that, we’re using web components, because I feel like they (along with Vue) are the best way to achieve that.

This solution has the following code files:

  • index.html
  • css/global.css
  • css/components/tabs.css
  • js/components/tabs-group.js

You can see a live demo of the final solution here and you can download a complete version of what we’re making in this solution, here.

HTML permalink

Let’s add this skeleton to your index.html file:

Code language
<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Front-End Challenges Club - 005</title>
    <link rel="stylesheet" href="/css/global.css" />
      <h1>My lovely product</h1>
    <script src="/js/components/tabs-group.js" type="module" defer></script>

This is our core structure and as you can see, it’s pretty simple. We’re pulling in our CSS, along with a CDN version of my modern reset that I use.

With this now in place: inside the <main> element and after the <h1>, add the following markup:

Code language
<tabs-group class="flow">
    <h2 data-element="trigger">Summary</h2>
    <div data-element="panel" class="flow">
      <p><strong>This product is good and you should buy it</strong></p>
        Cras justo odio, dapibus ac facilisis in, egestas eget quam. Nulla vitae elit
        libero, a pharetra augue. Maecenas sed diam eget risus varius blandit sit amet non
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam id dolor id nibh
        ultricies vehicula ut id elit. Cum sociis natoque penatibus et magnis dis
        parturient montes, nascetur ridiculus mus.

This, pals, is our minimum viable experience: articles with headed content. There are solutions out there that use anchor links (including the excellent inclusive components) as the minimum viable experience, but I think this is better. Headings are already navigable by screen readers and provide a solid outline for content, so this approach that we’re doing works well. Also, if (read: when) everything fails in terms of CSS and JavaScript, the content is easily consumed and easy to understand, regardless of ability.

You might have noticed that we added some data attributes to the heading and the inner content. These are going to do some pretty smart stuff later on in this solution, but for now, they don’t do anything.

The HTML above only accounts for one tab, so what you need to do now is create 4 more identical (apart from content) <article> elements. Don’t worry, if you don’t have much time, grab the HTML from this Gist.

The <tabs-group> element

You might be thinking “what the heck is that”? This is a custom element that we’ve yet to declare. This is what will be our web component, that magically creates our tabs from existing HTML.

This capability is what I absolutely love about custom elements, because it just works, even though I haven’t defined it yet. This is thanks to the forgiving, declarative nature of HTML. What a fantastic language, right?

For now and until this is defined, <tabs-group> essentially operates as a <div>. Lovely stuff.

CSS, part one permalink

Let’s add our custom properties and our global styles. Open up css/global.css and add the following:

Code language
:root {
  --color-light: #f3f3f3;
  --color-mid: #dedede;
  --color-dark: #313e4f;
  --color-slate: #444444;
  --color-slate-light: #72788e;
  --color-primary: #2a7bd6;
  --color-inactive: #4f637d;
  --font-base: 'Lato', sans-serif;
  --metric-rhythm: 1.5rem;

body {
  padding: 5rem 1.5rem;
  font-family: var(--font-base);
  color: var(--color-slate);

main {
  max-width: 40rem;
  margin: 0 auto;

h3 {
  color: var(--color-dark);
  line-height: 1.2;

h1 {
  font-size: 2.5rem;
  margin: 0 0 2rem 0;

h2 {
  font-size: 2rem;

We’ve got the same custom properties as the ones I set in the challenge with a couple of others added to define the font and a rhythm metric for our flow.

That’s all our globals styles, too, so when you refresh your browser, it should look pretty smart!

Let’s add our flow utility and some native flow for the <tabs-group>, which will only be affective while the user has no JavaScript capability.

Add this to global.css:

Code language
.flow {
  --flow-space: var(--metric-rhythm);

.flow > * + * {
  margin-top: 1em;
  margin-top: var(--flow-space);

tabs-group > * {
  --flow-space: 2rem;

Now when you look in the browser, you’ll see the minimum viable experience in all of its glory—some nicely formatted articles.

If I had it my way, we’d stop now, but that’s not why you’re here, so I’ll crack on 😉

JavaScript, part one permalink

We’ve written our default HTML and CSS and it’s looking great, but I promised you tabs, so let’s go for it.

As I’ve mentioned already, we’re creating a web component, so first, let’s get the basic shell in.

Add this to js/components/tabs-group.js:

Code language
class TabsGroup extends HTMLElement {
  constructor() {

    this.state = {
      activeTabIndex: -1,
      maxIndex: 0

    // Stores for triggers and panels
    this.triggers = [];
    this.panels = [];

if ('customElements' in window) {
  customElements.define('tabs-group', TabsGroup);

export default TabsGroup;

The first thing we do is create a new class which extends the base HTMLElement. All of your HTML elements in the wild are built on this base elemental class.

The next thing we do is set our constructor up, which is our class’s initialiser. Inside here, we call super() which tells the HTMLElement to construct and then we set our default state with a couple of arrays to store our triggers and panels.

Lastly, we feature detect custom element support. If it is supported, we define this new custom element. If there’s no support, our user will see what they currently see: some headed articles. This is progressive enhancement in action!

Next up: let’s add our first lifecycle method. Add this after the constructor function:

Code language
connectedCallback() {
  this.initialTriggers = [...this.querySelectorAll('[data-element="trigger"]')];
  this.initialPanels = [...this.querySelectorAll('[data-element="panel"]')];

  // Run the renderer to render the new tabs HTML in the shadow root

The connectedCallback runs when your custom element is appended to the DOM. Now, make a mental note of appended because this also means that it’ll fire if the element is moved around the DOM. For brevity though, we’ll presume our tabs won’t move.

Inside this function, we gather our existing triggers and panels. Notice how these calls use the data attributes we added to our initial headings and content panels. We use a spread to convert the result into a standard array, rather than a NodeList. This is mainly so we can use map in our render function.

Speaking of which, let’s add that to our component. Add the following after your connectedCallback:

Code language
render = async () => {
  const trigger = (text, index) => {
    return `
      <button class="tabs__button" data-index="${index}" data-element="trigger-button" role="tab">${text}</button>

  const panel = (html, index) => {
    return `
      <article class="tabs__panel" data-index="${index}" data-element="panel">${html}</article>

  try {
    const stylesRequest = await fetch('/css/components/tabs.css');

    if (![200, 304].includes(stylesRequest.status)) {
      return Promise.reject('Error loading CSS');

    const styles = await stylesRequest.text();

    const template = `
    <div class="tabs">
      <ul role="tablist" class="tabs__triggers">
          .map((item, index) => `<li>${trigger(item.innerText, index)}</li>`)
      ${, index) => panel(item.innerHTML, index)).join('')}

    // Set our root as a new open shadow root. This makes
    // rendering and setting HTML easier because we have a
    // class-level element to work with
    this.root = this.attachShadow({mode: 'open'});

    // Clear light DOM
    this.innerHTML = '';

    this.root.innerHTML = template;

  } catch (ex) {

This is similar to React and Vue. It’s not an official web component method, but I like the concept of a function called render that renders the content of the component. Most of it it is pretty self explanatory too, but let’s break some of it down.

We start with two internal functions (a bit like a closure) that render a trigger button and a panel. These are called for each item.

Then, we do something reasonably controversial: we load our CSS with fetch. The reason I’m doing this is because we’re using the Shadow DOM.

Without going into too much detail, the Shadow DOM is basically a spicy <iframe>. It’s spicy because we can still reach out and grab stuff like CSS Custom Properties (which we’ll do shortly), but document CSS doesn’t apply to your Shadow DOM by default. I don’t really like this, because stuff like the reset doesn’t get in, but we have to deal with what we’ve got, so instead of writing an inline <style> element, we’re just fetching our CSS instead. I like this separation of concern.

I use async and await to load the CSS with fetch. It makes the code nice and readable. This does mean that we use a try/catch though. Because we’ve made such a solid minimum viable experience, we’re safe in the knowledge that when this exception happens, our users will see that and not a broken component (if you refresh now, you’ll see that happening because the CSS doesn’t exist).

Once we’re happy that the CSS has arrived, we progress to the main rendering by injecting that CSS into the <style> element. We then loop the headings (initialTriggers) to create trigger buttons and then finally, we create panels with the content of each of the initialPanels items.

After that, we create a class-level property called this.root and inside it we open our shadow DOM, which is a bit like opening a port to another dimension. We then assign this new HTML to that shadow root.

Ok, breathe, that was a lot to take in. You’ll be happy to know that it all gets a bit simpler now!

CSS part two permalink

Our component is currently failing and throwing errors because our tabs.css component doesn’t exist yet, so let’s add it. Create /css/components/tabs.css and add the following:

Code language
.tabs__triggers {
  display: flex;
  padding: 0 2rem;
  margin: 0 0 var(--metric-rhythm) 0;
  border-bottom: 1px solid var(--color-mid);
  max-width: 100%;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;

.tabs__triggers li {
  list-style: none;
  flex-shrink: 0;

.tabs__triggers li + li {
  margin-inline-start: 1rem;

We’ve started with the container for our triggers. It’s a list that we’re turning into a nice inline layout with Flexbox. Notice how we’re also accounting for there being too many items here too. I like to use overflow: auto with -webkit-overflow-scrolling: touch giving us a nice bounce where supported. Finally, we add left margin to the inline-start which will be on the left in our HTML language, en.

Right, let’s tackle the buttons. Add this CSS:

Code language
.tabs__button {
  font-family: var(--font-base);
  font-size: 1.25rem;
  font-weight: bold;
  color: var(--color-inactive);
  background: transparent;
  padding: 0.25rem 0.2rem 0.5rem 0.25rem;
  border: none;
  border-bottom: 2px solid transparent;
  cursor: pointer;

.tabs__button[aria-selected='true'] {
  color: var(--color-dark);
  border-color: var(--color-primary);

This is pretty run-of-the-mill stuff. All we’re doing that’s worth talking about is adding a clear 2px bottom border that when the button is aria-selected, turns blue (primary). This is so that when the active state is displayed, the buttons don’t jump around and become misaligned.

Let’s add some focus styles:

Code language
.tabs__button:focus {
  outline: none;

.tabs__button:focus:not(:hover) {
  text-decoration: underline;
  text-decoration-skip-ink: auto;
  text-decoration-color: var(--color-slate-light);

We’re keeping it simple here. When the button is focused, we’re removing the outline, but don’t panic, under that, we’re adding an underline style when it’s focused but not hovered.

Next up, panel styles. Add this under your focus styles:

Code language
.tabs__panel {
  transition: opacity 200ms ease, transform 300ms linear;

.tabs__panel[data-state='hidden'] {
  opacity: 0;
  transform: translateY(0.5rem);
  visibility: hidden;
  height: 0px;
  overflow: hidden;

.tabs__panel[data-state='visible'] {
  padding: 1rem;
  opacity: 1;
  transform: none;
  transition-delay: 200ms;

.tabs__panel p:first-of-type {
  color: var(--color-dark);
  font-size: 1.5rem;
  line-height: 1.2;
  margin-top: 0;

.tabs__panel > * {
  max-width: 50ch;

.tabs__panel:focus {
  outline: 1px solid var(--color-mid);

We’re using one of my favourite tricks here: data attribute state hooks. I do this for two reasons. Firstly, it’s so much clearer than BEM modifiers, and secondly, I much prefer setting attributes with JavaScript than toggling classes.

The hidden state sets the panel to be 0px high and set’s visibility to hidden. Pro tip: setting visibility to hidden hides it from a screen reader, too, so no need for aria-hidden being toggled.

We also have the transitions on a delay, so the element is fully toggled before they kick in.

Finally, we set the first <p> to be a lede style paragraph, limit the width of the panel’s elements so they read nice and set the focus state to be a clean outline.

We are now done with CSS.

JavaScript, part two permalink

We’ll jump back into js/components/tabs-group.js and continue where we left off. Now we’ve got some CSS, it’s easier to get the rest of this component finished off.

We finished part one with the render() function. After that function, add the following:

Code language
postRender() {
	this.triggers = this.root.querySelectorAll('[data-element="trigger-button"]');
	this.panels = this.root.querySelectorAll('[data-element="panel"]');

	if (this.triggers.length !== this.panels.length) {
	  this.triggers.forEach(trigger => trigger.parentNode.removeChild(trigger));

	this.state.maxIndex = this.triggers.length - 1;
	this.toggle(0, true);


The first part is us grabbing the elements. I know I only want to loop them with a forEach in this method, so I use a standard querySelectorAll.

The second part is interesting. I check to see if the count of panels matches the count of triggers. If not, I remove the trigger buttons from the component and we again, revert back to a collection of headed articles, because we’d rather the minimum viable experience than a broken (or at least breakable) component.

The last bit is us setting what the maxIndex is by taking one off the length of the triggers (because of zero indexing). We then call our yet to be added toggle method.

After the call to this.toggle(0, true);, inside the postRender() function, add the following:

Code language
this.triggers.forEach((trigger, triggerIndex) => {
  trigger.addEventListener('click', evt => {


  trigger.addEventListener('keydown', evt => {
    switch (evt.keyCode) {
      case 39:
        this.modifyIndex('up', triggerIndex);
      case 37:
        this.modifyIndex('down', triggerIndex);
      case 40:
        // If the down key is pressed, we want to focus on the active panel

First up, we assign a click event to each trigger button. When the trigger is clicked, we use triggerIndex, which is the loop index to toggle its panel.

After that, we assign a keydown event to each trigger. We’re using a switch to only act if the left key (key code 39), the right key (key code 37) or the down key (key code 40) are pressed. The modifyIndex method (yet to be added) increments or decrements the index based on a passed direction. We do this because we want the previous or next tab to show if the user presses left or right. If the user presses down we want the related panel to focus.

This all makes it pretty darn usable for a keyboard user because we block focus on inactive tabs (upcoming toggle method). This is so that the active tab can be focused within and the tabs changed without many keystrokes, whereas if everything was focusable, it’d be easy to get a bit lost.

After this block of code, add the following:

Code language
this.panels.forEach(panel => {
  panel.addEventListener('keydown', evt => {
    switch (evt.keyCode) {
      case 38:
        // When the user keys up (when this is focused), focus on it's related tab

We do the same as with the triggers for the panels, but this time, we listen for a the up key (key code 38) and focus the corresponding tab if that’s pressed while the panel is focused (or a child element). This is again to make it easy for the keyboard user to get back to the tabs as quick as possible.

That’s the postRender() method done now, so lets add the following toggle() method after it:

Code language
toggle(index, isInitial = false) {

  if (index === this.state.activeTabIndex) {

  if (index > this.state.maxIndex) {
    index = this.state.maxIndex;

  this.state.activeTabIndex = index;

  this.triggers.forEach((trigger, triggerIndex) => {
    const panel = this.panels[triggerIndex];

    if (triggerIndex === index) {
      trigger.setAttribute('aria-selected', 'true');


      if (!isInitial) {

      panel.setAttribute('data-state', 'visible');
      panel.setAttribute('tabindex', '-1');
    } else {

      trigger.setAttribute('tabindex', '-1');
      panel.setAttribute('data-state', 'hidden');

This is the function that changes the active tab, so naturally, the first thing we do is test to see if the passed index is the active index. If it is, we bail out. Similarly, we test to see if the maxIndex has been exceeded. If it has, we set the index to be maxIndex and carry on. Now we know we’re all good, so we update our state by setting the activeTabIndex to the index value.

We then loop the triggers and first of all, grab the related panel, because we’re gonna need it. Then, if the triggerIndex matches the activeTabIndex, we set it to be aria-selected and we remove the tabindex attribute which has likely been set. This makes it focusable because it undoes what we’re doing next. We also set the panel’s data-state to be visible which if you remember from CSS part two, is when we transition it in.

Before we move on: if the isInitial flag is false, we go ahead and focus this trigger too, to make things more predictable for the user.

Next up, the tab is not active, so we reset all of the above and set the tabindex to be -1. This prevents keyboard focus, but allows us to programatically focus, using the focus() prototype method. We remove the panel’s tabindex so it can’t be focused.

Right, this is the last method and then we’re done. Add the following after the toggle() method:

Code language
modifyIndex(direction, triggerIndex) {
  // We only modify index if we are focused on the active tab or
  // it'll be a confusing user experience
  if (triggerIndex !== this.state.activeTabIndex) {

  switch (direction) {
    case 'down':
      this.toggle(this.state.activeTabIndex <= 0 ? this.state.maxIndex : this.state.activeTabIndex - 1);
    case 'up':
      this.toggle(this.state.activeTabIndex >= this.state.maxIndex ? 0 : this.state.activeTabIndex + 1);

We do the same initial check as in toggle() and bail if we’re not in the active state. Then we use some ternary operators to toggle a new index. If we exceed the maxIndex, we go back to 0 and if we hit or go below 0, we go the the maxIndex. This gives us an endless feel which is pretty handy if the user accidentally misses their stop.

With that, we are done!!

Wrapping up permalink

I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too!

My favourite attempt for this challenge was by Dana Byerly. Dana’s attempt is super close to this solution—especially with some solid keyboard work. Their attention to detail is always outstanding, too.