DEV Community

Cover image for Using Tailwind CSS and Alpine.js to Create Animated and Accessible Tabs
Cruip
Cruip

Posted on • Originally published at cruip.com

Using Tailwind CSS and Alpine.js to Create Animated and Accessible Tabs

Live Demo / Download

--

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/alpinejs@3.x.x/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>
Enter fullscreen mode Exit fullscreen mode

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

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

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 -&gt;</a>
        </div>
    </div>
</article>
Enter fullscreen mode Exit fullscreen mode

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 -&gt;</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 -&gt;</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 -&gt;</a>
                    </div>
                </div>
            </article>
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

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

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

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 to true if the button is active, and false otherwise
  • An aria-controls attribute, which should match the id 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>
    ...
Enter fullscreen mode Exit fullscreen mode

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

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

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

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/focus@3.x.x/dist/cdn.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

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

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

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()"
>
    ...
Enter fullscreen mode Exit fullscreen mode

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 -&gt;</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 -&gt;</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 -&gt;</a>
                    </div>
                </div>
            </article>
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)