Building tabs in Web Components

Part of my role at Nordhealth is to design, develop and expand upon our ever increasing roster of Web Components within the Nord Design System. One of my most recent contributions is arguably one component, but actually comprises of three Web Components. We're talking about tabs.

When I shared a small design detail from my tabs it got a lot of attention on Twitter.

0:00
/
Example tab component being resized which reveals a shadow in which the tabs disappear into when they overflow the width.

So I thought I'd take this opportunity to share some of the finer details about how I made them and some of the problems that needed to be overcome.

Tabs component structure

As mentioned earlier, tabs is actually made up of three components:

  • Tab: The tab button component itself
  • Tab panel: The component containing the content to be revealed
  • Tab group: The component which houses all the components above, arranges them in the right format and provides all the controls and behaviours
<nord-tab-group label="Title">
  <nord-tab slot="tab">Tab item 1</nord-tab>
  <nord-tab-panel>
    <p>
      Content item 1.
    </p>
  </nord-tab-panel>
  <nord-tab slot="tab">Tab item 2</nord-tab>
  <nord-tab-panel>
    <p>
      Content item 2.
    </p>
  </nord-tab-panel>
</nord-tab-group>
Example structure showing all components mentioned.

In our research we did see examples where one or more of these components were replaced with regular HTML elements, such as the <nord-tab-panel> being a regular <div>, however there was reasoning for this level of verbosity.

Firstly, we wanted to ensure that the right attributes were being applied to each element, that includes ARIA attributes as well. Secondly, we wanted to make sure we gave ourselves opportunities to return to these components and improve on them without having to perform fragile DOM manipulation within sibling or parent components.

There were several other structures we considered, all with their own benefits and drawbacks. The final structure we felt hit the balance of flexibility while still allowing us develop them in the future.

Semantics

As with many parts of the Nord Design System, we began with researching what already exists on the landscape. In the case of tabs we were provided with quite a few demos and examples all striking a similar semantic structure.

Tabs
Accessibility resources free online from the international standards organization: W3C Web Accessibility Initiative (WAI).
Tabbed Interfaces
When you think about it, most of your basic interactions are showing or hiding something somehow. I’ve already covered popup menu buttons and the simpler and less assuming tooltips and toggletips. You can add simple disclosure widgets, compound “accordions”, and their sister component the tabbed int…
ARIA: tab role - Accessibility | MDN
The ARIA tab role indicates an interactive element inside a tablist that, when activated, displays its associated tabpanel.

We benefited greatly from both the W3C resource, but possibly more so from Heydon's very in depth article on the semantic structure of tabbed interfaces.

There were so many explorations into tabs that there was even existing Web Components to learn from:

Spicy Sections | CSS-Tricks
What if HTML had “tabs”? That would be cool, says I. Dave has been spending some of his time and energy, along with a group of “Tabvengers” from OpenUI, on
GitHub - zachleat/seven-minute-tabs: A minimal no-theme Tabs web component.
A minimal no-theme Tabs web component. Contribute to zachleat/seven-minute-tabs development by creating an account on GitHub.

From all of this researched we gleaned a set of rules and recommendations as to how we structure our HTML and apply semantics. Here's a few points of interest:

  • The container of the interactive tab buttons should have a role of tablist, but also an aria-label for further clarity of what the list of tabs is intended for
  • Each tab itself should have a a role of tab to compliment the containing tablist
  • Each tab panel should have a role of tabpanel to further cement semantic meaning
  • aria-controls should be added to the tab and aria-labelledby to the tab panel so they can be used to reference each other, so that the panels have appropriate labels and the tabs accessibly describe what they control
  • tabindex should be used throughout but in an appropiate manner to guide focus through the elements
<nord-tab
    role="tab"
    id="nord-tab-group-1-tab-1"
    aria-controls="nord-tab-group-1-panel-1"
    selected
    aria-selected="true"
    tabindex="0"
>
    Tab item 1
</nord-tab>

<nord-tab-panel
    id="nord-tab-group-1-panel-1"
    aria-labelledby="nord-tab-group-1-tab-1"
    aria-hidden="false"
    role="tabpanel"
    tabindex="0"
>
    <p>Content item 1.</p>
</nord-tab-panel>
Example of the semantics that are applied to each component when rendered.

I'd love to go into further detail, however the articles and examples mentioned above do a far better job of this and I'd highly recommend you check them out.

Controls & interaction

There are multiple levels of control and interaction we need to provide in our Web Components. We need to allow people to click to reveal tabbed content. We need to allow people to accessibly reveal tabbed content with keyboard accesible controls. And at a higher level we need to allow developers to control these tabs like updating the selected tab or adding more content.

Mouse interaction

To access each panel when a tab is clicked upon we took advantage of the ARIA labelling we've already applied when the components were first rendered. The value of aria-controls on the tab is the id of the tab panel. On every tab panel we used aria-hidden set to true and then switch it to false when their respective tab is selected.

this.querySelectorAll("nord-tab-panel").forEach(panel => {
    panel.setAttribute("aria-hidden", `${panel !== selectedPanel}`)
})
Snippet example showing how we switch to reveal the selected tab.

We then hooked into the aria-hidden attribute in CSS to hide or show the panel. The method above of updating all the components inside the tab group is not ideal, but necessary since we're making adjustments at Light DOM level.

Keyboard interaction

I was keen to mirror the accessible tabs shown in the W3C tab examples mentioned above, which allowed you to use left and right keys as well as "home" and "end" keys to fully navigate the tabs. This leaves the common "tabbing" behaviour used to navigate the page in tact.

switch (event.key) {
    case "ArrowLeft":
        updateTab(this.direction.isLTR ? previousTab : nextTab, event)
        break

    case "ArrowRight":
        updateTab(this.direction.isLTR ? nextTab : previousTab, event)
        break

    case "Home":
        updateTab(firstTab, event)
        break

    case "End":
        updateTab(lastTab, event)
        break

    default:
        break
}
Case switch statement for listening out for particular keyboard presses.

We used a case switch statement to listen out for specific key presses. Note the this.direction.isLTR, this is due to left and right keys being reversed in languages that are written from right to left.

0:00
/
Example tab component navigating using keyboard controls, showing a focus ring around each tab.

Programmatic control

This is an area I'd like to improve on as I don't believe we're fully accounting for use cases within our products. However, it's good to practise manageable goals, getting an initial version into the wild can surface more important goals than you initially had in mind.

<nord-tab selected>Settings</nord-tab>
Example of just the tab component with selected attribute

To set the selected tab we set a boolean attribute on the tab component itself called selected. This makes changes to the tab internals as well as something to look out for in the tab group component.

mutations.forEach(mutation => {
    if (mutation.attributeName === "selected" && mutation.oldValue === null) {
        const selectedTab = mutation.target
        this.observer.disconnect()
        this.updateSelectedTab(selectedTab)
        this.observer.observe(this, TabGroup.observerOptions)
    }
})
Example of cycling through mutations surfaced by mutation observer in order to update the selected tab.

However we needed to do some additional work incase the selected tab is updated after the components are loaded. We made use of MutationObserver to listen out for a new selected tab and update all the component accordingly. Due to the updates we're making to the components we needed to pause an resume the observer with observer.disconnect and observer.observe to prevent a possible update infinite loop.

I highly recommend this article from Louis Lazaris as it's a super in depth article on how to use MutationObserver, the options avaialable and any possible gotchas:

Getting To Know The MutationObserver API — Smashing Magazine
Monitoring for changes to the DOM is sometimes needed in complex web apps and frameworks. By means of explanations along with interactive demos, this article will show you how you can use the MutationObserver API to make observing for DOM changes relatively easy.

We hope to improve in this area as time goes on and from the feedback loop we have in place for our Design System. Big thanks to Nick on giving guidance and sending me reference material.

CSS and UI details

I believe I spent equal time on the CSS as the JavaScript in the development of the tabs components, but we do like our attention to detail at Nordhealth.

Scrolling tab shadows

The most notable UI detail is the shadows that form at each end of the tab list when the tabs extend beyond it's width. While these may seem like a "CSS flex" (couldn't help the pun) they do serve a purpose, to let the user know there's more content beyond the edge of the bounding area and that it can be scrolled to.

0:00
/
Example of tabs scrolling off screen and under a shadow effect.

The technique I used was heavily based on a method developed by Lea Verou and Roma Komarov:

Pure CSS scrolling shadows with background-attachment: local – Lea Verou

This uses two pairs of gradients set to the background, one fixed and one that scrolls with the content (local). The one that scrolls with the content is transparent in the middle but the background color at each end, this would mask over the gradient which is acting as the overflow shadow. However for us we wanted to support Safari 14, which came with a number of issues.

The transparent keyword value is used for the gradient mask, and in Safari 14 transparent is treated as black with an alpha of 0. This creates a very muddy gradient when transitioning from white to transparent. The remedy is to use an rgba() value that mirrors the color the gradient is transitioning from. But this is a no-go for us as we use CSS Custom Properties for our tokens, meaning we can't manipulate the alpha and we're not keen on adding alpha versions of our colors just for a single browser bug.

Additioanlly the local keyword for background-attachment is a bit buggy in Safari. We ended up settling with a modified technique of Roman's original method, using ::before and ::after pseudo elements to mask over the gradient shadows at each end of the tab list. With the key difference being that we used inset box-shadows to achieve the gradient masking rather than a transparent gradient. This means we still get that nice gradiation of the shadow disappearing as the scrollable area reaches it's end.

.n-tab-group-list {
  list-style: none;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior: none;
  gap: var(--n-space-s);

  /* Two sets of background gradients to achieve the overflow shadow effect. */
  background-image: radial-gradient(ellipse farthest-side at 0% 50%, var(--n-color-border-strong) 0%, var(--n-tab-list-background)), radial-gradient(ellipse farthest-side at 100% 50%, var(--n-color-border-strong) 0%, var(--n-tab-list-background));
  background-repeat: no-repeat;
  background-position: 0 calc(var(--n-space-s) / 2), 100% calc(var(--n-space-s) / 2);
  background-size: var(--n-space-s) var(--n-space-xl), var(--n-space-s) var(--n-space-xl);
}

/* Starting and ending shadow masks of the tab list area. */
.n-tab-group-list::before,
.n-tab-group-list::after {
  content: "";
  box-sizing: content-box; /* Content box to use padding for spacing. */
  align-self: stretch; /* Match the height of the tabs. */
  min-inline-size: var(--n-space-l);
  margin-block-end: 1px; /* Prevent overlapping the key line. */
}

/* Cancel out the masks own width with margin, use padding for tab list padding */
.n-tab-group-list::before {
  margin-inline-end: calc(-1 * (var(--n-space-l) + var(--n-space-s)));
  padding-inline-start: var(--n-tab-group-padding);
}

.n-tab-group-list::after {
  margin-inline-start: calc(-1 * (var(--n-space-l) + var(--n-space-s)));
  padding-inline-end: var(--n-tab-group-padding);
  flex: 1; /* Stretch the last mask to make sure the shadow is always masked */
}

/* Inset shadows the same color as the background to graduate the shadow reveal. Adding right-to-left support by flipping the shadow direction. */
.n-tab-group-list::before,
.n-tab-group.is-rtl .n-tab-group-list::after {
  box-shadow: inset var(--n-space-l) 0 var(--n-space-s) calc(-1 * var(--n-space-s)) var(--n-tab-list-background);
}

.n-tab-group-list::after,
.n-tab-group.is-rtl .n-tab-group-list::before {
  box-shadow: inset calc(-1 * var(--n-space-l)) 0 var(--n-space-s) calc(-1 * var(--n-space-s)) var(--n-tab-list-background);
}
All the CSS used to create the tab list scrolling shadows effect.

I've done my best the comment the code above, but even I admit it's pretty wild. There's a few extra things happening in here which we're benefitting from. The use of inset box shadow is so that the shadow doesn't leak out of the tab list space. We're also using a combination of negative margin, padding and content-box sizing to control the padding on either end of the tab list depending on context. Trailing padding can get cut off in scrollable areas, meaning that scrolling to the end of the tab list would leave the last tab without any additional padding, the ::after pseudo element adds that padding back in.

Adjusting for tab weight changes

Another notable design detail is that despite each tab text changing weight when a tab is selected they don't cause a large width shift on adjacent tabs. Changing the weight of an inline element literally changes the width.

0:00
/
Selecting tabs with long lines of text but no large width shift when they turn bold.

A trick Viljami mentioned to me, which is used in the source code of GitHub, is to use pseudo elements to store the final tab width before it's selected

<div class="n-tab" data-text="The tab label">
    The tab label
</div>
Sample code from our tab component
.n-tab::before {
  content: attr(data-text);
  font-weight: var(--n-font-weight-active);
  display: block;
  block-size: 0;
  visibility: hidden;
}
Example code of how we set the width of the tab from the bolder font weight.

We've had to make some further modifications to this because our tab component can accept more than just text, but this trick works really well. The ::before pseudo element is pushing the width of tab slightly more because it's content is taken from the data-text attribute and is set to bold. When the tab is selected the main text content becomes bold but the element doesn't change width.

Rounding up

Tab Group | Nord Design System
Tab Group allows multiple panels to be contained within a single window,using tabs as a navigational element.

This was quite a learning experience. Web Components, accessibility, interaction and detailed CSS were all touched upon. We hope to develop these components in the future and work with our wider product team to take on board feedback and general improvements.

I really hope you found this deep dive useful, interesting or/and enjoyable. Would really appreciate it if you shared it on places like Twitter, and don't forget to mention me and the Nordhealth team!

Cheers ✌🏻