DEV Community

Vesa Piittinen
Vesa Piittinen

Posted on

Deep dive into WAI-ARIA tabs and accordions

Accessibility, now that is a thing that is known to benefit everyone, yet is a thing that is easily put aside when there is just not enough time and resources. The same has been true for myself in the past about five years! I've had far too many hats to wear, and it hasn't helped being the only one at my workplace who specializes on front end. Luckily we've been able to hire more people, and next year I hope to limit my role more than before by focusing on code reviews, a11y, and general usability improvements.

This text is one of my attempts to reintroduce myself to ARIA, and to level up myself from "I know about it" to "actually knowing about it" :)

WAI-ARIA

WAI-ARIA, the Accessible Rich Internet Applications Suite, defines a way to make Web content and Web applications more accessible to people with disabilities. It especially helps with dynamic content and advanced user interface controls developed with Ajax, HTML, JavaScript, and related technologies.

The most interesting part of WAI-ARIA for front end development is the part on authoring practices: from there you can find practical code samples that give a basis you can make your code work from. We'll have a look at three samples!

Accordions

In its core essence accordions are an UI component that allow to toggle content open and away. Most often accordions are represented in a vertical hierarchy, which is very similar to having a header and content following right after:

  • Header
  • Content
  • Header
  • Content (but hidden)
  • Header
  • Content (but hidden)

You activate items by clicking a header and the most typical setup is to keep one item always open. You can also find variants where you can toggle every item closed, or freely have any individual item open or closed.

Here is a shortened example of WAI-ARIA Authoring Practices accordion:

<div id="accordion">
    <h3>
        <button
            aria-controls="section-1"
            aria-expanded="true"
            id="header-1"
        >
            Personal Information
        </button>
    </h3>
    <div
        aria-labelledby="header-1"
        id="section-1"
        role="region"
    >
        <!-- Personal Information: content here -->
    </div>
    <h3>
        <button
            aria-controls="section-2"
            aria-expanded="false"
            id="header-2"
        >
            Billing Address
        </button>
    </h3>
    <div
        aria-labelledby="header-2"
        id="section-2"
        hidden=""
        role="region"
    >
        <!-- Billing Address: content here -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This is a lightweight structure as far as ARIA is concerned. Here button and div are linked together via references, and you only need to work with six properties: aria-controls, aria-expanded, aria-labelledby, hidden, id and role.

There are many more details on how this should behave: keyboard usage and how the attributes are toggled. Knowing about these is assumed later on. Take the time to read the WAI-ARIA document if you're unsure on how things should work!

Tabs

Tabs are typically split into two parts: first a row of buttons, and then content. One piece of content is always active and you use the buttons to select the one to display. With three items:

  • Button, Button, Button
  • Content, Content (but hidden), Content (but hidden)

Here is a shortened example of WAI-ARIA Authoring Practices tabs (manual activation):

<div id="tabs">
    <div aria-label="Entertainment" role="tablist">
        <button
            aria-controls="panel-1"
            aria-selected="true"
            id="tab-1"
            role="tab"
        >
            Nils Frahm
        </button>
        <button
            aria-controls="panel-2"
            aria-selected="false"
            id="tab-2"
            role="tab"
            tabindex="-1"
        >
            Agnes Obel
        </button>
    </div>
    <div
        aria-labelledby="tab-1"
        id="panel-1"
        role="tabpanel"
        tabindex="0"
    >
        <!-- Nils Frahm: content here -->
    </div>
    <div
        aria-labelledby="tab-2"
        hidden=""
        id="panel-2"
        role="tabpanel"
        tabindex="0"
    >
        <!-- Agnes Obel: content here -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Compared to accordions we have more stuff going on: more roles are defined and there is much more tabindex management. Tab buttons also use aria-selected instead of aria-expanded. Curiously tabindex="0" is defined on panel elements, which makes them tabbable.

The Differences

After investigation of the above HTML structures I must admit I like the accordion pattern more: it does very much the minimum required. The major extra step for tabs comes from the need of tabindex management. This need arises as only one tab must be accessible in the tablist element at a time. In contrast accordion headers are always accessible.

This structural difference is based on the assumption that the tab row needs to be a separate list. This was the easiest and most convenient way to style tabs five years ago, as that was the time we didn't have features like flexbox and grid. It was very difficult to come up with CSS that would work for tabs in a HTML structure that would be similar to the accordion pattern.

The Issues

A core theme reflected in WAI-ARIA is Ajax (fetch API calls) and its modern successor, SPA (Single Page App). Typically sites based on these patterns are either fully or partially unusable without JavaScript.

Looking a bit more critically to the WAI-ARIA sample codes from the perspective of pure HTML is that the buttons are type="submit" (by default), which means a form is submitted if accordion or tabs are inside one. In the other hand it should be noted doing that is stupid as these elements may contain a form, and form inside form is forbidden.

A more pragmatic critique is that one needs to reset button styles a lot. The reason buttons are used is based purely on the fact they are tabbable, and also work with Enter and Space natively. However, this seems a bit silly in the world of forced JS: if the whole thing works with only JavaScript then why take a small step into the past? Why make styling harder than it needs to be?

JavaScript only

In the world of SPA where your site only works with JavaScript enabled it doesn't make a lot of sense to take advantage of a few native browser behaviours if it costs you in the styling department. Additionally in modern React it seems to be better for most devs if more stuff is visible to the developer: if Enter and Space are in the code then it is easier to understand why things happen when you press the aforementioned keys, especially if the related code is commented.

What if we take the buttons away and control everything ourselves?

Accordion

<div id="accordion">
    <h3
        aria-controls="section-1"
        aria-expanded="true"
        id="header-1"
        tabindex="0"
    >
        Personal Information
    </h3>
    <div
        aria-labelledby="header-1"
        id="section-1"
        role="region"
    >
        <!-- Personal Information: content here -->
    </div>
    <h3
        aria-controls="section-2"
        aria-expanded="false"
        id="header-2"
        tabindex="0"
    >
        Billing Address
    </h3>
    <div
        aria-labelledby="header-2"
        id="section-2"
        hidden=""
        role="region"
    >
        <!-- Billing Address: content here -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The improvement here is that we don't have to remove default button stylings. There is of course some header overrides to be done, but these are often minimal and mostly what you'd set anyway (font: inherit, margin, padding).

The drawback is that you now have to manage all the keyboard actions yourself. In the other hand you're required to do some anyway, so this is hardly an issue. One added tabindex="0" is less HTML than adding a button inside.

Other option here could be to make use of a dl list, but I'm not yet familiar how screen readers work in that case. In dl list all h3s would be replaced with dts and the content divs would be dds. The disadvantage abandoning h3 is that screen reader users can't use key h to browse through headers.

Tabs

<div id="tabs">
    <ol aria-label="Entertainment" role="tablist">
        <li
            aria-controls="panel-1"
            aria-selected="true"
            id="tab-1"
            role="tab"
            tabindex="0"
        >
            Nils Frahm
        </li>
        <li
            aria-controls="panel-2"
            aria-selected="false"
            id="tab-2"
            role="tab"
            tabindex="-1"
        >
            Agnes Obel
        </li>
    </ol>
    <div
        aria-labelledby="tab-1"
        id="panel-1"
        role="tabpanel"
        tabindex="0"
    >
        <!-- Nils Frahm: content here -->
    </div>
    <div
        aria-labelledby="tab-2"
        hidden=""
        id="panel-2"
        role="tabpanel"
        tabindex="0"
    >
        <!-- Agnes Obel: content here -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

With tabs I ended up using ol element as tablist is a list. This then makes it clear to use li elements instead of buttons. I haven't tested this yet, but apparently ol lists always announce the number of items, which is a nice accessibility feature if that holds true.

Styling-wise there would be less to do: button resets are much more verbose than list resets.

The Other Way

It is always possible to question things further. What if we abandon SPA and JS-only mentality and instead thrive to work even without JavaScript? This does give an additional challenge as none of the above HTML structures would work. What would?

  1. Links: <a href=""> makes it possible to change page url and thus update HTML to reflect current selection. This is good if panel contents are loaded asynchronously.
  2. Forms: with <form> the control's current state could be submitted, including information of the newly selected tab. This then allows panel contents to be rendered only when needed.
  3. Inputs: <input type="radio"> and <input type="checkbox"> can provide styling based on CSS only. This also means each panel should be pre-rendered in HTML so that all content is accessible.

Links

The most straightforward way to use links is to make use of query params. As far as I can tell there is no standard how to imply this kind of state so what I have here is just something that would make it easy to write a general solution.

Links: Accordion

<div id="accordion">
    <h3>
        <a
            aria-controls="section-1"
            aria-expanded="true"
            href="?aria-expanded=accordion:0"
            id="header-1"
        >
            Personal Information
        </a>
    </h3>
    <div
        aria-labelledby="header-1"
        id="section-1"
        role="region"
    >
        <!-- Personal Information: content here -->
    </div>
    <h3>
        <a
            aria-controls="section-2"
            aria-expanded="false"
            href="?aria-expanded=accordion:1"
            id="header-2"
        >
            Billing Address
        </a>
    </h3>
    <div
        aria-labelledby="header-2"
        id="section-2"
        hidden=""
        role="region"
    >
        <!-- NO CONTENT RENDERED -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

In this structure link is inside h3 as this gives tabbability for free for the controlling element.

Note that only content for the selected accordion is rendered, but empty div exists for the unselected. This is a thing I don't know yet: is it better to remove ARIA attributes and which ones should be removed, or is it better to keep the empty element ready for action. The latter makes it easier for some JS solutions as there would be an existing element for async rendered stuff.

Links: Tabs

<div id="tabs">
    <ol aria-label="Entertainment" role="tablist">
        <li>
            <a
                aria-controls="panel-1"
                aria-selected="true"
                href="?aria-selected=tabs:0"
                id="tab-1"
                role="tab"
            >
                Nils Frahm
            </a>
        </li>
        <li>
            <a
                aria-controls="panel-2"
                aria-selected="false"
                href="?aria-selected=tabs:1"
                id="tab-2"
                role="tab"
                tabindex="-1"
            >
                Agnes Obel
            </a>
        </li>
    </ol>
    <div
        aria-labelledby="tab-1"
        id="panel-1"
        role="tabpanel"
        tabindex="0"
    >
        <!-- Nils Frahm: content here -->
    </div>
    <div
        aria-labelledby="tab-2"
        hidden=""
        id="panel-2"
        role="tabpanel"
        tabindex="0"
    >
        <!-- NO CONTENT RENDERED -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

One point here: unselected tabs tabindex="-1" should only be added in client-side JS. Why? It becomes impossible to access the link via keyboard if it would be rendered in server-side HTML. This does conflict with the WAI-ARIA's recommendation, but in the other hand it does not concern itself with this use case (JavaScript disabled).

Forms

One reason to use forms with accordions or tabs would be the need to automatically updating given settings even when switching between content. Alternatively it could be used to remember all active options in a multistep form. In this case it probably makes sense to render all contents in server-side HTML as the other option would be to render <input type="hidden"> so that it would be guaranteed to preserve all given fields. The latter results in less HTML, but might become troublesome to maintain.

As a third idea you could go with mix-and-match: some of the contents contain a form, but others may have extra information that is better to serve in HTML if needed.

Forms: Accordion

<form action="" id="accordion">
    <h3>
        <button
            aria-controls="section-1"
            aria-expanded="true"
            id="header-1"
            name="aria-expanded"
            value="accordion:0"
        >
            Personal Information
        </button>
    </h3>
    <div
        aria-labelledby="header-1"
        id="section-1"
        role="region"
    >
        <!-- Personal Information: content here -->
    </div>
    <h3>
        <button
            aria-controls="section-2"
            aria-expanded="false"
            id="header-2"
            name="aria-expanded"
            value="accordion:1"
        >
            Billing Address
        </button>
    </h3>
    <div
        aria-labelledby="header-2"
        id="section-2"
        hidden=""
        role="region"
    >
        <!-- NO CONTENT RENDERED -->
    </div>
</form>
Enter fullscreen mode Exit fullscreen mode

Here we are close to original WAI-ARIA sample as buttons have been restored. The added special sauce is the logic to tell server which tab is visible. Also, the whole control is a form.

You could also separate each piece of content to their own form, although going that route you cannot automatically preserve filled information when switching between content.

Forms: Tabs

<div id="tabs">
    <form action="" aria-label="Entertainment" role="tablist">
        <button
            aria-controls="panel-1"
            aria-selected="true"
            id="tab-1"
            name="aria-selected"
            role="tab"
            value="tabs:0"
        >
            Nils Frahm
        </button>
        <button
            aria-controls="panel-2"
            aria-selected="false"
            id="tab-2"
            name="aria-selected"
            role="tab"
            tabindex="-1"
            value="tabs:1"
        >
            Agnes Obel
        </button>
    </form>
    <div
        aria-labelledby="tab-1"
        id="panel-1"
        role="tabpanel"
        tabindex="0"
    >
        <!-- Nils Frahm: content here -->
    </div>
    <div
        aria-labelledby="tab-2"
        hidden=""
        id="panel-2"
        role="tabpanel"
        tabindex="0"
    >
        <!-- NO CONTENT RENDERED -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This sample has a major downside as the form only knows about the specific tab. You could wrap the whole thing into a form to get similar benefits to the previous links sample. However sometimes you don't want to automatically preserve stuff as tab is switched and this provides the minimal HTML to get that use case done.

Inputs

This is the most complex case as it brings more CSS considerations into the structure of the HTML. In addition to input we also get label elements into the mix. This makes things probably unnecessarily complex for tabs, and lacking enough experience with screen readers I'll instead only provide a sample with accordions:

<div id="accordion">
    <input
        aria-controls="section-1"
        aria-expanded="true"
        checked=""
        id="header-1"
        type="radio"
    />
    <h3>
        <label htmlFor="header-1" id="header-label-1">
            Personal Information
        </label>
    </h3>
    <div
        aria-labelledby="header-label-1"
        id="section-1"
        role="region"
    >
        <!-- Personal Information: content here -->
    </div>
    <input
        aria-controls="section-2"
        aria-expanded="false"
        id="header-2"
        type="radio"
    />
    <h3>
        <label htmlFor="header-2" id="header-label-2">
            Billing Address
        </label>
    </h3>
    <div
        aria-labelledby="header-label-2"
        id="section-2"
        role="region"
    >
        <!-- Billing Address: content here -->
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The biggest problem while writing this was that I wanted to stay minimal, but I'm not fully sure if this works as intended (especially with screen readers). Regardless there are now big differences:

  • <input> element should be hidden visually using class="sr-only" or class="visually-hidden" or whatever you use. It needs to stay accessible via keyboard so display: none; is not the way to go.
  • <input> must be before related elements so that targetting styles via CSS is possible.
  • hidden attribute is nowhere to be found: CSS has to handle the case.
  • aria-expanded is troublesome! I think it should only be added with the help of client-side JS.

There are also some styling issues to be solved!

Adding CSS-only transition support for content while also staying screen reader friendly is kinda hard, because you need to make content hidden somehow without access to HTML attributes and JS...

The only way around the previous would be to abandon ARIA entirely and simply let all content to be available to be read, but this then might result into user confusion as screen reader would be reading stuff that is not actually visible. Without JS the best way is probably to forget about transitions, unless there are now working ways to transition from display: none.

Summary

This text has been very much a research into where WAI-ARIA stands with tabs and accordions, where it might be a bit off, and about things it doesn't account for, but which might make sense to account for.

Tabs and accordions are very close to each other as far as their functionality is concerned. They do appear visually different and there is some functionality (toggle, multiple) that are possible with accordions that are not possible with tabs. Historically HTML has also been seen as a limiting factor on how tabs can be structured, and WAI-ARIA has clearly followed this old limitation.

But do we really need tablist, tab and tabpanel? Do we need separation of tabs and accordions in future WAI-ARIA? These are the kind of questions I want to ask now that I'm thinking on what kind of future react-tabbordion should go as it has become outdated.

Top comments (0)