Using Tailwind CSS and Alpine.js to Create Animated and Accessible Tabs
Tabs coordinate a variety of use cases and tasks in the interface design. You can see them in action for displaying multiple contents compared to each other or for highlighting the difference between various elements and information in a particular section. We use tabs to group under a specific umbrella 1+ elements that are connected but differ in some ways.
At Cruip, we have long been fans of tabs and implemented them in multiple Tailwind CSS templates. For example, you can see them in use in our recruitment website template called Talent, or our elegant HTML website template called Tidy, or our SaaS website template called Open Pro.
These examples give you a glimpse into how versatile tabs are in interface design and why they are so popular on landing pages or websites.
Ok, as usual, let’s begin by creating the HTML document that will serve as the container for our animated tabs component. We will import Tailwind CSS and Alpine.js using the CDN. It’s important to note that using the CDN is not recommended for production websites, but for our experiment, it will suffice:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Unconventional Tabs</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Caveat:wght@500&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
inter: ['Inter', 'sans-serif'],
caveat: ['Caveat', 'cursive'],
},
},
},
};
</script>
</head>
<body class="relative font-inter antialiased">
<main class="relative min-h-screen flex flex-col justify-center bg-slate-50 overflow-hidden">
<div class="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
<!-- Tabs component -->
</div>
</main>
</body>
</html>
Building the structure for the tabs component
Now, let’s start building the structure for our tabs component. It will be organized as follows:
<div x-data="{ activeTab: 1 }">
<!-- Buttons -->
<div class="flex justify-center">
<div class="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
<!-- Button #1 -->
<!-- Button #2 -->
<!-- Button #3 -->
</div>
</div>
<!-- Tab panels -->
<div class="max-w-[640px] mx-auto">
<div class="relative flex flex-col">
<!-- Panel #1 -->
<!-- Panel #2 -->
<!-- Panel #3 -->
</div>
</div>
</div>
As you can see, we have already defined a JavaScript object using Alpine.js to manage the state of our component. This object includes a property called activeTab
, which is initially set to 1
. This value represents the index of the active tab, allowing us to display the corresponding panel.
Adding the buttons
Now, let’s add the navigation buttons. We’ll begin with the first button and then replicate the structure for the other ones:
<button
id="tab-1"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 1 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
@click="activeTab = 1"
>Lassen Peak</button>
We have a rather simple HTML structure, styled using Tailwind CSS utility classes. Additionally, we have some JavaScript logic to handle the button’s state. The conditional class (:class
) dynamically changes the text and background color based on the value of the activeTab
property. The @click
event updates the activeTab
property when the button is clicked.
It’s important to note that when we replicate this structure for the other buttons, we’ll need to update the value of the activeTab
property based on the button’s index. For instance, the second button should have a value of 2
, and the third button should have a value of 3
. Similarly, update the button’s id
attribute according to its index.
Building the tab panels
Now, let’s build the panels for our component. We’ll begin with the first panel and then replicate the structure for the remaining panels:
<article
id="tabpanel-1"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
x-show="activeTab === 1"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-01.jpg" alt="Tab 01" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Lassen Peak</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
To ensure smooth animations for the panels entering and exiting the view, we utilized Alpine.js’ x-transition
directives along with Tailwind CSS classes. The entry animation has a duration of 0.7 seconds (duration-700
), while the exit animation has a duration of 0.3 seconds (duration-300
). Additionally, we’ve implemented a custom easing effect to simulate a slight bounce using arbitrary variants (ease-[cubic-bezier(0.68,-0.3,0.32,1)]
).
Let’s replicate the structure for the remaining two panels. Make sure to modify the content and update the x-show
value to display the right panel based on the active tab.
Let’s proceed to complete the missing buttons and panels. The complete code will be as follows:
<div x-data="{ activeTab: 1 }">
<!-- Buttons -->
<div class="flex justify-center">
<div class="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
<!-- Button #1 -->
<button
id="tab-1"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 1 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
@click="activeTab = 1"
>Lassen Peak</button>
<!-- Button #2 -->
<button
id="tab-2"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 2 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
@click="activeTab = 2"
>Mount Shasta</button>
<!-- Button #3 -->
<button
id="tab-3"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 3 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
@click="activeTab = 3"
>Eureka Peak</button>
</div>
</div>
<!-- Tab panels -->
<div class="max-w-[640px] mx-auto">
<div class="relative flex flex-col">
<!-- Panel #1 -->
<article
id="tabpanel-1"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
x-show="activeTab === 1"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-01.jpg" alt="Tab 01" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Lassen Peak</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
<!-- Panel #2 -->
<article
id="tabpanel-2"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
x-show="activeTab === 2"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-02.jpg" alt="Tab 02" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Mount Shasta</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
<!-- Panel #3 -->
<article
id="tabpanel-3"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
x-show="activeTab === 3"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-03.jpg" alt="Tab 03" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Eureka Peak</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
</div>
</div>
</div>
This code already provides us with a fully functional component, but there is still much to be done in terms of accessibility. In today’s web development practices, the importance of accessibility is often unknown or underestimated. However, ensuring equal access to information for all individuals is a fundamental aspect that should not be overlooked. In this tutorial, we will focus on enhancing the accessibility of our component, making it more inclusive and user-friendly.
Making the component accessible
Using ARIA roles and attributes
ARIA stands for “Accessible Rich Internet Applications” and represents a set of roles and attributes that aim to make web applications more accessible to all users. ARIA is supported by all modern browsers and allows us to provide additional information to HTML elements. For instance, we can use ARIA to specify the role or state of elements. This enables screen readers to interpret and convey this extra information to users with disabilities.
To begin, let’s assign the role of tablist
to the container element that holds the buttons, and assign the role of tab
to each individual button element:
<div role="tablist" class="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12">
<!-- Button #1 -->
<button
id="tab-1"
role="tab"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 1 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
@click="activeTab = 1"
>Lassen Peak</button>
...
Next, let’s add the role of tabpanel
to each of the 3 panels:
<article
id="tabpanel-1"
role="tabpanel"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
x-show="activeTab === 1"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
...
Now, in addition to the previously mentioned ARIA roles, we need to define some additional ARIA attributes for our buttons.
Each button element should have the following attributes:
- An
aria-selected
attribute, which should be set totrue
if the button is active, andfalse
otherwise - An
aria-controls
attribute, which should match theid
of the corresponding panel
<button
id="tab-1"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 1 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
:aria-selected="activeTab === 1"
aria-controls="tabpanel-1"
@click="activeTab = 1"
@focus="activeTab = 1"
>Lassen Peak</button>
...
As you can see, we have used the Alpine.js x-bind directive, in the shorthand version :aria-selected
, to dynamically define the boolean value of the aria-selected attribute.
For the panels, we simply need to add an aria-labelledby attribute, where the value is the same as the id
of the corresponding button. This association allows screen readers to read the description or label to visually impaired users.
<article
id="tabpanel-1"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
role="tabpanel"
aria-labelledby="tab-1"
...
>
Using tabindex attributes
To allow screen readers to navigate from the active tab to its corresponding tab panel, we need to ensure that only the active button has a tabindex="0"
attribute, while all other buttons have a tabindex="-1"
attribute. We’ll again use Alpine.js’ x-bind
directive (:tabindex
) to dynamically assign these values:
<button
id="tab-1"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 1 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
:tabindex="activeTab === 1 ? 0 : -1"
:aria-selected="activeTab === 1"
aria-controls="tabpanel-1"
@click="activeTab = 1"
@focus="activeTab = 1"
>Lassen Peak</button>
...
Finally, we will add a tabindex="0"
to the tabpanel
to include it in the page’s Tab sequence.
<article
id="tabpanel-1"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
role="tabpanel"
tabindex="0"
aria-labelledby="tab-1"
...
Improving keyboard accessibility
As a final step, we want to ensure that keyboard navigation of our tabs component provides an optimized experience. Currently, due to the custom tabindex
attributes we’ve applied, the natural keyboard navigation flow is disrupted, and users cannot navigate between the buttons using the Tab
key. Instead, when the focus is on a tab
, pressing the Tab
key moves the focus to the corresponding tabpanel
.
So, how can we enable navigation between the buttons when one of them is focused? We can achieve this by utilizing the Left
and Right
arrow keys! To implement this functionality, we can rely on the helpful Focus plugin provided by Alpine.js.
First, ensure that you include the library in the head tag of your HTML page:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
Now, we can start using the plugin directives in our code:
<div
role="tablist"
class="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12"
@keydown.right.prevent.stop="$focus.wrap().next()"
@keydown.left.prevent.stop="$focus.wrap().prev()"
>
<!-- Button #1 -->
<button ... @focus="activeTab = 1">Lassen Peak</button>
<!-- Button #2 -->
<button ... @focus="activeTab = 2">Mount Shasta</button>
<!-- Button #3 -->
<button ... @focus="activeTab = 3">Eureka Peak</button>
</div>
In summary, we are ensuring the following three things:
- When the
Right
key is pressed, move the focus to the next button. - When the
Left
key is pressed, move the focus to the previous button. - When the focus is on one of the buttons, update the value of the
activeTab
variable to display the correspondingtabpanel
.
But we’re not finished yet! To provide an optimal experience in line with accessibility guidelines, we want users to be able to quickly return to the first tab by pressing the Home
key, and to the last tab by pressing the End
key. To achieve this, we will use the @keydown.home
and @keydown.end
directives:
<div
role="tablist"
class="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12"
@keydown.right.prevent.stop="$focus.wrap().next()"
@keydown.left.prevent.stop="$focus.wrap().prev()"
@keydown.home.prevent.stop="$focus.first()"
@keydown.end.prevent.stop="$focus.last()"
>
...
Now we’re done! We have created an accessible tabs component optimized for keyboard navigation. Here’s the complete code:
<div x-data="{ activeTab: 1 }">
<!-- Buttons -->
<div class="flex justify-center">
<div
role="tablist"
class="max-[480px]:max-w-[180px] inline-flex flex-wrap justify-center bg-slate-200 rounded-[20px] p-1 mb-8 min-[480px]:mb-12"
@keydown.right.prevent.stop="$focus.wrap().next()"
@keydown.left.prevent.stop="$focus.wrap().prev()"
@keydown.home.prevent.stop="$focus.first()"
@keydown.end.prevent.stop="$focus.last()"
>
<!-- Button #1 -->
<button
id="tab-1"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 1 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
:tabindex="activeTab === 1 ? 0 : -1"
:aria-selected="activeTab === 1"
aria-controls="tabpanel-1"
@click="activeTab = 1"
@focus="activeTab = 1"
>Lassen Peak</button>
<!-- Button #2 -->
<button
id="tab-2"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 2 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
:tabindex="activeTab === 2 ? 0 : -1"
:aria-selected="activeTab === 2"
aria-controls="tabpanel-2"
@click="activeTab = 2"
@focus="activeTab = 2"
>Mount Shasta</button>
<!-- Button #3 -->
<button
id="tab-3"
class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
:class="activeTab === 3 ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
:tabindex="activeTab === 3 ? 0 : -1"
:aria-selected="activeTab === 3"
aria-controls="tabpanel-3"
@click="activeTab = 3"
@focus="activeTab = 3"
>Eureka Peak</button>
</div>
</div>
<!-- Tab panels -->
<div class="max-w-[640px] mx-auto">
<div class="relative flex flex-col">
<!-- Panel #1 -->
<article
id="tabpanel-1"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
role="tabpanel"
tabindex="0"
aria-labelledby="tab-1"
x-show="activeTab === 1"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-01.jpg" alt="Tab 01" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Lassen Peak</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
<!-- Panel #2 -->
<article
id="tabpanel-2"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
role="tabpanel"
tabindex="0"
aria-labelledby="tab-2"
x-show="activeTab === 2"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-02.jpg" alt="Tab 02" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Mount Shasta</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
<!-- Panel #3 -->
<article
id="tabpanel-3"
class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
role="tabpanel"
tabindex="0"
aria-labelledby="tab-3"
x-show="activeTab === 3"
x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform order-first"
x-transition:enter-start="opacity-0 -translate-y-8"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-12"
>
<figure class="min-[480px]:w-1/2 p-2">
<img class="w-full h-[180px] min-[480px]:h-full object-cover rounded-lg" width="304" height="214" src="./tabs-image-03.jpg" alt="Tab 03" />
</figure>
<div class="min-[480px]:w-1/2 flex flex-col justify-center p-5 pl-3">
<div class="flex justify-between mb-1">
<header>
<div class="font-caveat text-xl font-medium text-sky-500">Mountain</div>
<h1 class="text-xl font-bold text-slate-900">Eureka Peak</h1>
</header>
<button class="shrink-0 h-[30px] w-[30px] border border-slate-200 hover:border-slate-300 rounded-full shadow inline-flex items-center justify-center focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" aria-label="Like">
<svg class="fill-red-500" xmlns="http://www.w3.org/2000/svg" width="14" height="13">
<path d="M6.985 1.635C5.361.132 2.797.162 1.21 1.7A3.948 3.948 0 0 0 0 4.541a3.948 3.948 0 0 0 1.218 2.836l5.156 4.88a.893.893 0 0 0 1.223 0l5.165-4.886a3.925 3.925 0 0 0 .061-5.663C11.231.126 8.62.094 6.985 1.635Zm4.548 4.53-4.548 4.303-4.54-4.294a2.267 2.267 0 0 1 0-3.275 2.44 2.44 0 0 1 3.376 0c.16.161.293.343.398.541a.915.915 0 0 0 .766.409c.311 0 .6-.154.767-.409.517-.93 1.62-1.401 2.677-1.142 1.057.259 1.797 1.181 1.796 2.238a2.253 2.253 0 0 1-.692 1.63Z" />
</svg>
</button>
</div>
<div class="text-slate-500 text-sm line-clamp-3 mb-2">It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.</div>
<div class="text-right">
<a class="text-sm font-medium text-indigo-500 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 transition-colors duration-150 ease-out" href="#0">Read more -></a>
</div>
</div>
</article>
</div>
</div>
</div>
Conclusions
Hopefully, you have enjoyed this tutorial and learned a thing or two that you can apply to your next project to take advantage of tabs to enhance the quality of your interface design. Keep reading if you need to build something similar for Next.js or Vue: