DEV Community

Grandschtroumpf
Grandschtroumpf

Posted on

Tabs - from scratch

Let's build this tabs component from scratch:

Tabs with view animation API

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
<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>
Enter fullscreen mode Exit fullscreen mode

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
<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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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',
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

tabs

.tabs {
  display: flex;
  gap: 8px;
}
.tabs[aria-orientation="horizontal"] {
  flex-direction: column;
}
.tabs[aria-orientation="vertical"] {
  flex-direction: row;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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%;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)',
    });
  }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)