HTML
Let's set all attributes required to have the best a11y.
tabs
The parent element has aria-orientation
to let the user knows how to navigate between the tabs. There is no specific role
for this element.
<div class="tabs" aria-orientation="horizontal" >
...
</div>
tablist
The tablist
element will manage the list of tab button. It'll also handle keyboard navigation between tabs (see below):
<div role="tablist" aria-label="Sample Tabs">
...
</div>
Note: there is no need to use a ul
element has the role tablist
is already more precise.
tab
Each tab
element will be a button to manage focus and default keyboard click. Let's review the attributes required:
role="tab"
-
type="button"
: prevent button to besubmit
if tabs are in a<form>
element. -
id="tab-1"
: Needed to associate the tab as label of the panel -
aria-selected="true"
: If the tab is currently selected -
aria-controls="panel-1"
: The id of the panel controlled by the tab -
tabindex="0"
: Used to ease navigation between the selected tab and it's panel
<button
role="tab"
type="button"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
First Tab
</button>
<button
role="tab"
type="button"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Second Tab
</button>
The first tab is selected by default so aria-selected
is set to "true" and tabindex
is set to 0. The second tab has tabindex
set to -1, so it cannot be focused with Tab
, we'll implement focus with the Arrow keys instead later.
tabpanel
The tabpanel
are direct children of the .tabs
element. Let's review their attributes:
role="tabpanel"
-
id="panel-1"
: Used by the tab'saria-controls
-
aria-labelledby="tab-1"
: The panel is labelled by its tab -
tabindex="0"
: All panels should be focusable (div is not focusable by default) -
hidden
: Hide all panels that are not selected
<div role="tabpanel" id="panel-1" tabindex="0" aria-labelledby="tab-1">
<p>Content for the first panel</p>
</div>
<div role="tabpanel" id="panel-2" tabindex="0" aria-labelledby="tab-2" hidden>
<p>Content for the second panel</p>
</div>
HTML result
Here is the final result:
<div class="tabs" aria-orientation="horizontal" >
<div role="tablist" aria-label="Sample Tabs">
<button
role="tab"
type="button"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
First Tab
</button>
<button
role="tab"
type="button"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Second Tab
</button>
</div>
<div role="tabpanel" id="panel-1" tabindex="0" aria-labelledby="tab-1">
<p>Content for the first panel</p>
</div>
<div role="tabpanel" id="panel-2" tabindex="0" aria-labelledby="tab-2" hidden>
<p>Content for the second panel</p>
</div>
</div>
Javascript
Let's interact with our tabs
Click
When we click on a tab we want to select it:
- Change
aria-selected
&tabindex
on both previous & next tab - Change
hidden
on both previous & next panel
Let's add an event listener on the tab :
<button role="tab" ... onclick="select(event)">...</button>
In a script let's handle this event :
function select(event) {
// Get the element
const nextTab = event.target;
const panelId = nextTab.getAttribute('aria-controls');
const previousPanel = document.querySelector('[role="tabpanel"]:not([hidden])');
// If next panel id if the same as the previous one, stop
if (previousPanel.id === panelId) return;
const previousTab = document.querySelector(`[role="tab"][aria-controls="${previousPanel.id}"]`);
const panel = document.getElementById(panelId);
// update DOM
previousTab.setAttribute('aria-selected', 'false');
previousTab.setAttribute('tabindex', '-1');
previousPanel.setAttribute('hidden', '');
nextTab.setAttribute('aria-selected', 'true');
nextTab.setAttribute('tabindex', '0');
nextPanel.removeAttribute('hidden');
// If there is a scrollbar, scroll into view
nextTab.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
Keyboard navigation
We have removed the default Tab
navigation with tabindex="-1"
, now it's time to manage it with the Arrow keys.
In aria-orientation="horizontal"
we want:
-
ArrowRight
: focus next tab -
ArrowLeft
: focus previous tab -
Home
: focus first tab of the tablist -
End
: focus last tab of the tablist
In aria-orientation="vertical"
we want:
-
ArrowDown
: focus next tab -
ArrowUp
: focus previous tab -
Home
: focus first tab of the tablist -
End
: focus last tab of the tablist
Let's add the event listener on the tablist
element:
<div role="tablist" ... onkeydown="navigate(event)">
...
</div>
And in a script handle the event
// Query elements (do it on each function if tabs are dynamic)
const root = document.querySelector('.tabs');
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
function focusFirstTab() {
tabs.at(0)?.focus();
}
function focusLastTab() {
tabs.at(-1)?.focus();
}
function focusNextTab() {
const focusedEl = document.activeElement;
if (!focusedEl) return focusFirstTab();
const index = Array.from(tabs).findIndex((el) => el === focusedEl)
const nextIndex = (index + 1) % tabs.length;
tabs[nextIndex].focus();
}
function focusPreviousTab() {
const focusedEl = document.activeElement;
if (!focusedEl) return focusLastTab();
const index = Array.from(tabs).findIndex((el) => el === focusedEl)
const nextIndex = (index - 1 + tabs.length) % tabs.length;
tabs[nextIndex].focus();
}
/** Navigate between tabs */
function navigate(event) {
const isHorizontal = root.getAttribute('aria-orientation') === 'horizontal';
if (isHorizontal) {
// Prevent horizontal scroll
if (['ArrowRight', 'ArrowLeft', 'End', 'Home'].includes(event.key)) event.preventDefault();
if (event.key === 'ArrowRight') focusNextTab();
if (event.key === 'ArrowLeft') focusPreviousTab();
} else {
// Prevent vertical scroll
if (['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key)) event.preventDefault();
if (event.key === 'ArrowDown') focusNextTab();
if (event.key === 'ArrowUp') focusPreviousTab();
}
if (event.key === 'End') focusLastTab();
if (event.key === 'Home') focusLastTab();
}
This is a lot of code, but not very fancy. Depending on the keyboard navigation it'll focus a tab of the tablist
.
Note: We don't need to handle keyboard selection because button
already triggers a click
event on Enter
and spacebar.
CSS
Let's make this tab a little bit fancier.
Root
I usually set some custom properties in the :root
to have coherent styling.
To get a great contrast I'll use oklch because contrast stay consistent across different hues. With a 80% difference between background and text I'm sure to have a good contrast:
:root {
--hue: 250; /* blue. change between 0 and 360 to have your favorite color */
--primary: oklch(60% 0.3 var(--hue));
--outline: oklch(60% 0 var(--hue));
--background: oklch(98% 0.03 var(--hue));
--text: oklch(20% 0.03 var(--hue));
color-sheme: light dark;
accent-color: var(--primary);
}
Let's build the dark mode:
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(15% 0.03 var(--hue));
--text: oklch(98% 0.03 var(--hue));
}
}
We are only changing the --background
& --text
because --primary
and --outline
work fine in both scheme.
And a default body :
body {
height: 100dvh;
font-family: system-ui;
color: var(--text);
background-color: var(--background);
}
tabs
.tabs {
display: flex;
gap: 8px;
}
.tabs[aria-orientation="horizontal"] {
flex-direction: column;
}
.tabs[aria-orientation="vertical"] {
flex-direction: row;
}
Setting flex-direction: row
is not required as this is the default, but for this article I'll be explicit.
tablist
[role="tablist"] {
display: flex;
padding: 4px 8px;
gap: 8px;
overflow: auto;
scroll-behavior: smooth;
--tablist-scroll-width: 4px;
&::-webkit-scrollbar-track {
background: oklch(80% 0 var(--hue));
}
&::-webkit-scrollbar-thumb {
background: oklch(60% 0 var(--hue));
}
}
.tabs[aria-orientation="horizontal"] [role="tablist"] {
flex-direction: row;
&::-webkit-scrollbar {
height: var(--tablist-scroll-width, 4px);
}
}
.tabs[aria-orientation="vertical"] [role="tablist"] {
flex-direction: column;
&::-webkit-scrollbar {
width: var(--tablist-scroll-width, 4px);
}
}
I'm using [role="tablist"]
as selector. I think it works great for a demo, but you might want to use classes in component library to avoid conflicts.
scroll
If the tabs overflow, you want to have a nice scrollbar or it'll ruin the experience. I've used a local custom property --tablist-scroll-width
to easily update it in both horizontal & vertical mode.
tab
We'll want a indicator of the selected tab. For that I'm using the pseudo-element before
to add line between the tab and the panel.
Base style:
[role="tab"] {
flex-shrink: 0;
cursor: pointer;
color: currentcolor;
background-color: transparent;
border: none;
border-radius: 4px;
padding: 16px;
}
.tabs[aria-orientation="horizontal"] [role="tab"] {
text-align: center;
}
.tabs[aria-orientation="vertical"] [role="tab"] {
text-align: start;
}
We use flex-shrink:0
to let the tab overflow its container if needed (too many tabs, small screen, ...).
Interactivity
[role="tab"]:hover {
background-color: color-mix(in oklch, var(--text) 12%, var(--background));
}
[role="tab"]:active {
background-color: color-mix(in oklch, var(--text) 24%, var(--background));
}
[role="tab"]:focus-visible {
outline: solid 1px var(--primary);
}
Here is a little trick. We use color-mix
to manage :hover & :active colors. By mixing the --background
& the --text
we are sure it'll work in both light & dark schemes.
By setting the alpha to 12% & 24% user will feel the interactivity without breaking contrast.
For the focus I use --primary
to get a consistent experience across all browsers (Firefox will use accent-color
by default, but not Chrome for example).
Selected tab
[role="tab"][aria-selected="true"] {
position: relative;
}
[role="tab"][aria-selected="true"]::before {
content: "";
position: absolute;
border-radius: 4px;
background-color: var(--primary);
}
.tabs[aria-orientation="horizontal"] [role="tab"][aria-selected="true"]::before {
inset-block-end: -2px;
inset-inline: 0;
height: 1px;
}
.tabs[aria-orientation="vertical"] [role="tab"][aria-selected="true"]::before {
inset-inline-end: -2px;
inset-block: 0;
width: 1px;
}
For the selected tab we'll display a line between the tablist
and the tabpanel
. We use inset-inline-end
instead of left
/ right
to manage dir="rtl"
and dir="ltr"
without media query.
tabpanel
This one is easy
[role="tabpanel"]:not([hidden]) {
flex: 1;
display: grid;
place-items: center;
}
We want to target only the tabpanel that are no hidden because we set display
.
Animation
Now comes the fun part. For the animation we'll use the View Transition API. It's currently only working on Chromium browsers.
tab
We want to move the underline between the tabs, for that we'll need to set the view-transition-name
on the ::before
pseudo-element.
[role="tab"][aria-selected="true"]::before {
...
view-transition-name: selected-tab;
}
⚠️ There should be only one element with the same view-transition-name
pre frame. This is why we only set it on the aria-selected
tab.
Now we need to tell the browser when to run transition. In our case it's during select
function :
function select(event) {
...
// Move all DOM update into a closure
function updateDOM() {
previousTab.setAttribute('aria-selected', 'false');
previousTab.setAttribute('tabindex', '-1');
previousPanel.setAttribute('hidden', '');
nextTab.setAttribute('aria-selected', 'true');
nextTab.setAttribute('tabindex', '0');
nextPanel.removeAttribute('hidden');
nextTab.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
// If document support view transition api
if ('startViewTransition' in document) {
document.startViewTransition(updateDOM);
} else {
updateDOM();
}
}
Try it out!
Now let's change the second tab text to something longer :
<button id="tab-2" ...>
Super long label for a tab
</button>
Then, the animation looks weird. This is because of the default transition.
The reason is because "it will maintain its aspect ratio rather than filling the group". So if tabs have different width, the animation will keep ratio of the previous one.
To fix this we need to update the css :
::view-transition-new(selected-tab),
::view-transition-old(selected-tab){
height: 100%;
}
With this, the tab will not try to keep the aspect ratio.
To improve transition we can change the animation easing, for that we need to target the ::view-transition-group(selected-tab)
as it is the one responsible for the change of position.
::view-transition-group(selected-tab) {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
And voilà ! you've got a beautiful animation for tabs.
Gotchas :
- if selected-tab is outside of the tablist, it'll be visible because view transition creates elements outside of the
body
. - if you've got multiple tabs component in your app you cannot use this method because you'll have several time the same
view-transition-name
. There is a solution, but I won't discuss it in this article.
tabpanel
Almost there ! Now we need to animate the entering & exiting panels. Let's do a slide effect. First let add a view-transition-name
on the visible panel:
[role="tabpanel"]:not([hidden]) {
...
view-transition-name: selected-panel;
}
For this effect we'll need to know the order of the panels. For that we'll use compareDocumentPosition and trigger the animation with the Web Animation API :
if ('startViewTransition' in document) {
// Animate tabs
const transition = document.startViewTransition(updateDOM);
// Animate panels
await transition.ready;
// Get animation orientation
const translate = root.getAttribute('aria-orientation') === 'horizontal'
? 'translateX'
: 'translateY';
// Check order of panels in the DOM
const dir = previousPanel?.compareDocumentPosition(nextPanel) === Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
// Animation happen on the document element
document.documentElement.animate({
transform: [
`${translate}(0)`,
`${translate}(${100 * dir}%)`
]
},{
duration: 500,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
// Target the panel leaving
pseudoElement: '::view-transition-old(selected-panel)',
});
document.documentElement.animate({
transform: [
`${translate}(${-100 * dir}%)`,
`${translate}(0)`
]
},{
duration: 500,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
// Target the panel entering
pseudoElement: '::view-transition-new(selected-panel)',
});
}
Awesome ! But now the panel enter and leave outside of the box. To prevent this we need to set an overflow on the ::view-transition-group(selected-panel)
:
::view-transition-group(selected-panel) {
overflow: hidden;
animation-duration: 0.5s;
}
::view-transition-new(selected-panel),
::view-transition-old(selected-panel){
animation: none;
}
I also removed the default animation on new & old to avoid the fade effect, you can keep it if you like it.
Conclusion
Finally ! You didn't thought it would take this amount of work for a simple tab right ?
I hope you liked it. I'll be experimenting with the View Transition API with other component so stay tune !
Top comments (0)