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
<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>
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.


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
role
oftablist
, but also anaria-label
for further clarity of what the list of tabs is intended for - Each tab itself should have a a
role
oftab
to compliment the containingtablist
- Each tab panel should have a
role
oftabpanel
to further cement semantic meaning aria-controls
should be added to the tab andaria-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 controltabindex
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>
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}`)
})
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
}
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.
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>
selected
attributeTo 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)
}
})
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:

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.
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.
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-shadow
s 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);
}
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
<div class="n-tab" data-text="The tab label">
The tab label
</div>
.n-tab::before {
content: attr(data-text);
font-weight: var(--n-font-weight-active);
display: block;
block-size: 0;
visibility: hidden;
}
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

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 ✌🏻