Tabs - from scratch
Tags: a11y, animation, css, javascript
Published: 2023-08-23 08:02:14

Let's build this tabs component from scratch:
{% codepen https://codepen.io/GrandSchtroumpf/pen/eYbmaJr %}
<figcaption>Tabs with view animation API</figcaption>

## 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.
```html
<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): 
```html
<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 be `submit` 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
```html
<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's `aria-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

```html
<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:
```html
<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 : 
```html
<button role="tab" ... onclick="select(event)">...</button>
```
In a script let's handle this event :
```javascript
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:
```html
<div role="tablist" ... onkeydown="navigate(event)">
  ...
</div>
```
And in a script handle the event
```javascript
// 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:
```css
: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: 
```css
@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 : 
```css
body {
  height: 100dvh;
  font-family: system-ui;
  color: var(--text);
  background-color: var(--background);
}
```

### tabs
```css
.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
```css
[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:
```css
[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
```css
[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
```css
[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
```css
[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](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_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. 
```css
[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 :
```javascript
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 : 
```html
<button id="tab-2" ...>
  Super long label for a tab
</button>
```
Then, the animation looks weird. This is because of the [default transition](https://developer.chrome.com/docs/web-platform/view-transitions/#default-style-and-transition-reference).
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 : 
```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.
```css
::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:
```css
[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](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition) and trigger the animation with the [Web Animation API](https://developer.chrome.com/docs/web-platform/view-transitions/#animating-with-javascript) :
```javascript
  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)`:
```css
::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 !