loading...

Tabs in React: Bringing the Past to the Future

merri profile image Vesa Piittinen ・11 min read

By now tabs component is a very old UI invention and has been around quite a while. We've seen many examples of how tabs should not be done (multirow tabs anyone?), while lately accessibility message has finally gotten through as we now see ARIA mentioned in almost every UI component library out there. This is great development as a11y is something I've tried to get right years ago, but gotten it wrong as the information around the web has been awfully conflicting and open for incorrect interpretation. While there are still sources that are awful the increase of good information allows anyone checking multiple sources to correct their mistakes.

Tabs of the past

During the jQuery days, right before React became a thing, the holy grail of tabs design was the following:

  • Structure tabs as single components instead of splitting into tab bar container and panels container.
  • Allow for any height of content instead of fixed sizes (without JavaScript).
  • Be usable with only CSS: work even with JavaScript disabled.

Since around IE9 level of browser capabilities all of this was possible to achieve! There were some limitations of course, starting from the fact the layout had to be based on hacky CSS, but that was all we had before flexbox and grid anyway.

I solved these requirements somewhere around 2013, and later in a response to Chris Coyier's post on functional tabs revisited I posted the code to CodePen.

<div class="tabs">

  <div class="tab">
    <input class="tab-radio" type="radio" id="tab-X" name="tab-group-Y" checked>
    <label class="tab-label" for="tab-X">TAB TITLE</label>
    <div class="tab-panel">
      <div class="tab-content">
        TAB CONTENT GOES HERE
      </div>
    </div> 
  </div>

</div>

This structure was very hard to make work with CSS: it was much easier to just have tab labels inside their own container and the related contents in their own. The advantage of the above structure is that it keeps related content in the same context. This makes it much easier to add or remove tabs as all related code is in one place.

It is somewhat true that using radio elements is a bit on the hacky side, but it is still one of the only ways you can make the right content appear without JavaScript.

With (now legacy) tricks the above HTML can be made appear as tabs:

  1. font-size: 0 to remove space between inline-block elements (tab labels).
  2. .tab must be inline to get inline-block elements to align on the same row.
  3. Radio elements must be hidden, but so that keyboard access is retained.
  4. .tab-label is inline-block so they get to their own row.
  5. .tab-panel has overflow: hidden and other hacks to workaround cross-browser issues (the price of IE6, IE7 and IE8 support!)
  6. .tab-content has width: 100% and float: left, which together force the content to jump below the labels.

I must admit I still love CSS hacks and working around limitations! :) Modern CSS, blergh, you can do everything without a headache ^__^;; (nope, not serious).

Accessibility issues

The thing that I got badly wrong in the code above is that I used div elements far too much: I should've used ol and li for each tab as this tells the number of elements in screen readers. Each example that lacks semantically correct elements is a bad example, so that is certainly something I regret: one should do HTML properly even when the main focus is showing a tricky CSS sample. This is better for everything: search engines, screen readers, and understandability for a developer who reads the code later – it is super awful to read HTML where everything is a div, you have no mental anchors anywhere!

In the other hand Chris Coyier's original code sample claimed accessibility by hiding the radio elements entirely by using display: none. This indeed made the tabs appear as just one continuous content to a screen reader so they wouldn't know about tabs at all and got access to all content, but you also lost native keyboard access for switching between the tabs. The reason for having tabs is also lost in this case: you use tabs to group information or functionality that you let user have optional access to. This point isn't fulfilled if everything is just a long block of content.

To fix these problems we can use ARIA attributes! So lets upgrade that old HTML:

<ol aria-label="Choose content with arrow keys:" class="tabs" role="tablist">

  <li class="tab">
    <input
      aria-controls="tab-1-panel"
      aria-labelledby="tab-1-label"
      aria-selected="true"
      checked
      class="sr-only visually-hidden"
      id="tab-1"
      name="tab-group"
      role="tab"
      type="radio"
    />
    <label class="tab-label" id="tab-1-label" for="tab-1">SELECTED</label>
    <div
      class="tab-panel"
      id="tab-1-panel"
      role="tabpanel"
      tabindex="0"
    >
      VISIBLE CONTENT
    </div>
  </li>

  <li class="tab">
    <input
      aria-controls="tab-2-panel"
      aria-labelledby="tab-2-label"
      aria-selected="false"
      class="sr-only visually-hidden"
      id="tab-2"
      name="tab-group"
      role="tab"
      type="radio"
    />
    <label class="tab-label" id="tab-2-label" for="tab-2">UNSELECTED</label>
    <div
      aria-hidden="true"
      class="tab-panel"
      id="tab-2-panel"
      role="tabpanel"
      tabindex="-1"
    >
      HIDDEN CONTENT
    </div>
  </li>

</ol>

Okay, that is a lot of new stuff! I'll go through things extensively.

Things I'm pretty sure about

  • aria-label in ol: you need to tell the context of the tabs somewhere.
  • class="sr-only visually-hidden": sr-only and visually-hidden seem to be the modern conventions for visually hidden content that is targeted for screen readers. You use the one you like, or your own.
  • aria-controls: tells which panel is controlled by a tab.
  • aria-selected: indicates the panel is selected (checked is just HTML state).
  • aria-labelledby: input element can have multiple labels, so let screen reader know what this is (could also use aria-label to give different kind of instruction for screen reader user).
  • roles: tablist, tab and tabpanel are the three required ones.
  • aria-hidden="true" and tabindex="-1" in panel to hide content that is not active.

Things that I'm not as sure about

  • tabindex="0" on active panel content: this makes the content focusable and tabbable. The reason I would like to do this as a developer is to be able to remove active focus indication from a clicked tab (thus still allowing clear focus indication to appear in keyboard usage), but I'm still unsure whether this is the right thing to do.
  • Not having tabindex="-1" in unselected tabs: radio element appears kind of as one element, so you can only access individual items via arrow keys.
  • Using radio elements as tabs: this structure is built to preserve as much native browser behavior as possible (even when using JS). It could be argued that label elements should be the ones with role="tab" and all the related aria attributes, and then hide the radio elements from screen readers entirely.

Optional stuff that I'm not sure about

  • You could indicate aria-expanded in the li elements, but is that the correct element, and is doing that useful at all? It could be useful for styling though!
  • You could give aria-orientation to the role="tablist" element to indicate horizontal and vertical tabs, but that is yet another thing I don't know if it has any practical value. Yet another thing that could be used for styles via CSS!

Other considerations

There seems to be support for aria-disabled. I can somewhat understand it, but I've started to notice that most often it might be better to not display unavailable option at all. Avoiding disabled makes for both a greatly simpler design and less confusing experience, but I have to admit this is a thing I still need to do further reading on.

The biggest issue with the above code is that rendering those ARIA rules into HTML as such will destroy a11y when JavaScript is disabled. I know designing for JavaScript disabled is a thing most developers don't want to even consider, because coding for it adds another layer of complexity. But! Those ARIA rules are mostly required to indicate JavaScript state.

But you can make things work. In React for example you can simply toggle different rules after component has mounted, so when rendering server side HTML you would end up with this result instead:

<ol class="tabs" role="tablist">

  <li class="tab">
    <input
      aria-controls="tab-1-panel"
      checked
      class="hidden"
      id="tab-1"
      name="tab-group"
      role="tab"
      type="radio"
    />
    <label class="tab-label" id="tab-1-label" for="tab-1">SELECTED</label>
    <div
      aria-labelledby="tab-1-label"
      class="tab-panel"
      id="tab-1-panel"
      role="tabpanel"
    >
      VISIBLE CONTENT
    </div>
  </li>

  <li class="tab">
    <input
      aria-controls="tab-2-panel"
      class="hidden"
      id="tab-2"
      name="tab-group"
      role="tab"
      type="radio"
    />
    <label class="tab-label" id="tab-2-label" for="tab-2">UNSELECTED</label>
    <div
      aria-labelledby="tab-2-label"
      class="tab-panel"
      id="tab-2-panel"
      role="tabpanel"
    >
      VISUALLY HIDDEN CONTENT
    </div>
  </li>

</ol>

Here is a summary of changes:

  1. aria-label removed from ol as it instructs JS-enabled behavior.
  2. aria-labelledby and aria-selected removed from radio element.
  3. radio's class is changed to hidden (= display: none) to disable screen reader access to tabs.
  4. aria-labelledby is now in the role="tabpanel" element so screen reader will tell the context of content.
  5. aria-hidden and tabindex are fully removed from role="tabpanel".

Essentially all content is then available, although as one long span of content, and there is no indication to a screen reader that these are actually tabs.

What can be confusing about this is usage for users who still have limited vision: things that screen reader announces wouldn't match visually with what can be seen. I don't know if this matters, but if it does I can't see a way out of this niche issue - as a reminder we're talking about screen reader with JavaScript disabled.

Do you know better about all of the above than I do? Let me know in the comments!

Not the only HTML structure out there!

So far we've talked about probably the least common technical solution for tabs when paired with JavaScript, especially modern React. I ported the above to React back in the v0.13 days, although with ARIA mistakes, and some other silly choices I have fixed once react-tabbordion v2 is done.

A thing I've been researching for v2 is all the different HTML structures out there. Because so far most of the Tabs and Accordion components out there do force you into specific structure, which I think leaves another niche I would like to fill: let user of a component focus on building tabs the way they want, and for the need they have.

The reason for my thinking is that not one Tabs component answers to all the needs. Looking around the web I can find several kinds of solutions:

  1. <ol role="tablist" /> + <li role="tab" />: this has minimal HTML footprint while being a proper list.
  2. role="tablist" + <button role="tab" />: probably the most common one, and often with no list elements.
  3. <nav role="tablist" /> + <a href="#" role="tab" />: allows for tabs that are links to another HTML page (optionally, when JS is disabled). Haven't seen any that would be presented also as list elements.

Each of these can be made to work with JavaScript disabled!

The <li role="tab" /> option allows for only one usage: all content must be pre-rendered in HTML, and the tablist must be entirely hidden from screen readers, only allowing access to the content as one span of content. However, as there is no state in HTML, there should be no tablist with tabs rendered: only all the content within the panels in one visible list. The only reason to use this would be the compactness of the HTML, thus shorter and faster load times.

The <button role="tab" /> option could be made to work as a form: this would be useful if each panel is to be loaded only when required. It could also allow to post changes made into input fields inside a panel even without JavaScript. As an advantage you don't need to have visually hidden content rendered into HTML, only the content that matters visually! In this case it makes sense to keep all the tab items focusable.

Then, the <a href="#" role=tab" /> option provides another kind of possibilities. You could have multiple forms within a single panel, you can have the tab as a true link that would serve another HTML page for a panel, and you can have the links as anchors to panels that are rendered into the HTML. You could also mix and match, and you can certainly keep the links clickable even when JS is disabled as you can make everything work visually even with only CSS (using :target to show the correct panel and indicate active tab).

As the final option we could compare these to the radio list structure. The advantage of radio list is the most solid CSS that it can provide via :checked. This can give noJS-experience that is roughly up to par with JavaScript, where for example :target can be a bit odd (as it relies on url hashes). The biggest downside is that all the panels must be pre-rendered in HTML when supporting JavaScript disabled.

Summary of no-JS

  • <li role="tab" /> least syntax, but depends heavily on JS implementation, all panels must be rendered to HTML, content would flash upon JS hydration as you must have all content visible with no-JS (unless you try to workaround using <noscript />...).
  • <button role="tab" /> would work as form, but cannot have forms inside panels. Each panel should be separated to their own URL.
  • <a href="#" role=tab" /> gives most possibilities: you can indicate active state via CSS, you can have panels that are only loaded on demand, and you can have panels that are pre-rendered into HTML. The CSS functionality without JS wouldn't be optimal, though.
  • <input type="radio" role="tab" /> (or <label role="tab" />) has the best CSS-only state possibilities, but all panels must be rendered to HTML in advance.

Did I get something wrong? Did I miss a HTML structure that is out there in the wild? Let me know!

The JavaScript side of things

So far we've talked about quite a rare thing: nobody targets JavaScript disabled these days! That is so 90's! Lets just do things that works for most people!

But that kind of mentality is the reason for so many issues we have with the web, and with humanity in general: when you ignore something, you're eventually ignoring people. In business sense that means lost visitors, and in turn lost customers. In practical sense you're most likely too busy to care, if not that, then the other options are to be lazy, or actually being a person who doesn't care. Personally I've certainly been in the too busy department for far too long!

These days we have reached a point where standards are very good, and we have far less browser issues to worry about. Internet Explorer 11 is still a thing for some of us, but even it has enough support that you can make tolerable fallback styles and functionality for it.

All this leaves more room to focus on stuff that remains hard due to required amount of knowledge:

  1. Solid CSS architecture
  2. Accessibility
  3. Semantic HTML (or meaningful in case you think semantic has lost it's meaning; pun intended)
  4. JavaScript disabled

Most of these fronts are about basic usability: keeping things working under all conditions, and making stuff available for everyone in every possible way. You provide much better quality and experience to end users by taking these things to account. Although the CSS part is more of an issue for large scale development.

The most common thing each of these share is the neglect given to them by too many JavaScript developers. And that I can understand: I, too, have been deep into the React and Node world the past few years. So much has been happening around JS that it has blinded from other issues. And we got mobile phones too, that brought a completely new level of complexity to web design!

Coming to 2020 I think it is time to take a break and take a look back into the greater picture: who do we serve, how would they like to use what we do, and how we really should solve these problems. React, for example, is used because of developer experience. Same for CSS-in-JS, modern JavaScript itself, and many other recent techs. Many don't bring any real advances to end users, which is very sad.

And here I've been talking about tabs, ending up talking about how to make the world a better place :)

Posted on by:

merri profile

Vesa Piittinen

@merri

Web Front End Specialist who doesn't want to identify as Full Stack, but knows how to get stuff done. Loves perf and minimalism. HTML + CSS + Web Standards over JS. UX over DX. Hates div disease.

Discussion

markdown guide