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.
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
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.
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.
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:
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
tablist, but also an
aria-labelfor further clarity of what the list of tabs is intended for
- Each tab itself should have a a
tabto compliment the containing
- Each tab panel should have a
tabpanelto further cement semantic meaning
aria-controlsshould be added to the tab and
aria-labelledbyto 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
tabindexshould be used throughout but in an appropiate manner to guide focus through the elements
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.
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.
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.
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.
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.
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.
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.
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.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:
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
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.
The technique I used was heavily based on a method developed by Lea Verou and Roma Komarov:
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.
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.
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
::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.
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.
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
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.
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!