Tutorials / / 7 min read
How to create persistent tabs with Tailwind CSS: JavaScript and Alpine.js
Build accessible tabs with Tailwind CSS that remember the active tab in localStorage, two ways: vanilla JavaScript or Alpine.js.
Monday again, another day of tutorials! Today we are creating a persistent tabs component, twice: first with vanilla JavaScript and Tailwind CSS, then with Alpine.js, so you can pick whichever fits your stack.
What are persistent tabs?
Persistent tabs remember the user’s last selected tab across page reloads and return visits, usually by storing the active index in localStorage. Users keep their place and context, don’t waste time reselecting their preferred tab, and complex multi-section pages feel less demanding to navigate.
Use cases
- Dashboards: reopen straight to the section you were monitoring.
- Settings pages: come back to the category you were editing.
- Project management tools: keep your preferred view, whether Kanban board, timeline, or list.
- Multi-step forms: pick up at the step you left off.
The markup
The wrapper
The wrapper is where the whole component lives.
id="tabsComponent": assigns a unique ID to the wrapper. This ID will be used to target the wrapper in the JavaScript code.
<div id="tabsComponent">
<!-- Tabs go here -->
</div>The tab list
The buttons live inside a ul. The list gets role="tablist", and each li gets role="presentation" to indicate it is a purely presentational element.
<ul role="tablist">
<!-- Tabs go here -->
</ul>The buttons
Attributes
role="tab": indicates that the element is a tab.id="tab-1": assigns a unique ID to the tab.aria-controls="panel-1": associates the tab with its corresponding panel.
<button role="tab" id="tab-1" aria-controls="panel-1">My account</button>The panels
The panels are section elements where the content of the tabs will be displayed.
Attributes
role="tabpanel": indicates that the element is a tabpanel.id="panel-1": assigns a unique ID to the panel.aria-labelledby="tab-1": associates the panel with the corresponding tab.
<section role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Content 1
</section>We will do the same with the other panel, only the second panel will have its own attributes.
Note: Classes are removed for brevity and you can get the full code on the GitHub repository.
<div id="tabsComponent">
<!-- Tab List -->
<ul role="tablist">
<!-- Tab 1 -->
<li role="presentation">
<button role="tab" id="tab-1" aria-controls="panel-1">My account</button>
</li>
<!-- Tab 2 -->
<li role="presentation">
<button role="tab" id="tab-2" aria-controls="panel-2">Billing</button>
</li>
</ul>
<!-- Panels -->
<div>
<!-- Panel 1 -->
<section role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Content 1
</section>
<!-- Panel 2 -->
<section role="tabpanel" id="panel-2" aria-labelledby="tab-2">
Content 2
</section>
</div>
</div>The script
The selectors
const tabsComponent = document.getElementById("tabsComponent");: selects the wrapper element.const tabButtons = tabsComponent.querySelectorAll(".tab-button");: selects all the buttons inside the wrapper.const tabPanels = tabsComponent.querySelectorAll(".tab-panel");: selects all the panels inside the wrapper.
const tabsComponent = document.getElementById("tabsComponent");
const tabButtons = tabsComponent.querySelectorAll(".tab-button");
const tabPanels = tabsComponent.querySelectorAll(".tab-panel");The setActiveTab function
function setActiveTab(index): sets the active tab based on the index passed as an argument.button.setAttribute("aria-selected", i === index);: marks the active button as selected.button.setAttribute("tabindex", i === index ? "0" : "-1");: keeps only the active button focusable.button.classList.toggle("bg-orange-50", i === index);andbutton.classList.toggle("text-orange-600", i === index);: style the active tab.
tabButtons.forEach((button, i) => {
button.setAttribute("aria-selected", i === index);
button.setAttribute("tabindex", i === index ? "0" : "-1");
button.classList.toggle("bg-orange-50", i === index);
button.classList.toggle("text-orange-600", i === index);
});The panels iteration
panel.style.display = i === index ? "block" : "none";: shows the active panel and hides the rest.
tabPanels.forEach((panel, i) => {
panel.style.display = i === index ? "block" : "none";
});The localStorage
localStorage.setItem("activeTab", index.toString());: saves the active tab index so it survives reloads.
localStorage.setItem("activeTab", index.toString());The button click event listener
- Each button gets a click listener that calls
setActiveTabwith the index of the button as an argument.
tabButtons.forEach((button, index) => {
button.addEventListener("click", () => setActiveTab(index));
});Setting the initial active tab
const storedActiveTab = parseInt(localStorage.getItem("activeTab")) || 0;: reads the saved index from local storage, falling back to the first tab.setActiveTab(storedActiveTab);: applies it on load.
const storedActiveTab = parseInt(localStorage.getItem("activeTab")) || 0;
setActiveTab(storedActiveTab);The complete script
document.addEventListener("DOMContentLoaded", function () {
const tabsComponent = document.getElementById("tabsComponent");
const tabButtons = tabsComponent.querySelectorAll(".tab-button");
const tabPanels = tabsComponent.querySelectorAll(".tab-panel");
function setActiveTab(index) {
tabButtons.forEach((button, i) => {
button.setAttribute("aria-selected", i === index);
button.setAttribute("tabindex", i === index ? "0" : "-1");
button.classList.toggle("bg-orange-50", i === index);
button.classList.toggle("text-orange-600", i === index);
});
tabPanels.forEach((panel, i) => {
panel.style.display = i === index ? "block" : "none";
});
localStorage.setItem("activeTab", index.toString());
}
tabButtons.forEach((button, index) => {
button.addEventListener("click", () => setActiveTab(index));
});
// Set initial active tab
const storedActiveTab = parseInt(localStorage.getItem("activeTab")) || 0;
setActiveTab(storedActiveTab);
});The Alpine.js version
Same component, no separate script: the state lives in x-data.
x-data: holdsactiveTab, initialized from localStorage, plus asetActiveTab(index)method that updates the state and saves it back.@click="setActiveTab(0)": activates a tab and persists the choice.:aria-selected="activeTab === 0"and:tabindex="activeTab === 0 ? 0 : -1": keep the buttons accessible, focusable only when active.:class="{ 'bg-orange-50 text-orange-600': activeTab === 0 }": applies the orange styling to the active tab.x-show="activeTab === 0": shows only the active panel.
The wrapper carries the state:
<div
x-data="{
activeTab: parseInt(localStorage.getItem('activeTab')) || 0,
setActiveTab(index) {
this.activeTab = index;
localStorage.setItem('activeTab', index.toString());
}
}"
>
<!-- Tabs goes here -->
</div>The tab list and its items keep the same roles as before:
<ul role="tablist" class="flex items-stretch -mb-px text-slate-500">
<!-- Tabs go here -->
</ul><li role="presentation">
<!-- Button goes here -->
</li>A tab button with the Alpine directives in place:
<button
role="tab"
id="tab-1"
aria-controls="panel-1"
:aria-selected="activeTab === 0"
:tabindex="activeTab === 0 ? 0 : -1"
@click="setActiveTab(0)"
:class="{
'bg-orange-50 text-orange-600': activeTab === 0
}"
class="flex items-center h-10 px-6 py-2 text-sm font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-orange-500"
>
My account
</button>The other button is the same, but with a different id and aria-controls attribute, since the buttons are associated with their respective panels.
A panel:
<section
id="panel-1"
role="tabpanel"
aria-labelledby="tab-1"
x-show="activeTab === 0"
class="p-8"
>
Content 1
</section>Panel 2 is similar to Panel 1, but with a different id and aria-labelledby attribute.
The full markup
As you can see the markup is quite simple.
<div
x-data="{
activeTab: parseInt(localStorage.getItem('activeTab')) || 0,
setActiveTab(index) {
this.activeTab = index;
localStorage.setItem('activeTab', index.toString());
}
}"
>
<!-- Tab List -->
<ul role="tablist" class="flex items-stretch -mb-px text-slate-500">
<!-- Tab 1 -->
<li role="presentation">
<button
@click="setActiveTab(0)"
:aria-selected="activeTab === 0"
:tabindex="activeTab === 0 ? 0 : -1"
:class="{ 'bg-orange-50 text-orange-600': activeTab === 0 }"
class="flex items-center h-10 px-6 py-2 text-sm font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-orange-500"
role="tab"
id="tab-1"
aria-controls="panel-1"
>
My account
</button>
</li>
<!-- Tab 2 -->
<li role="presentation">
<button
@click="setActiveTab(1)"
:aria-selected="activeTab === 1"
:tabindex="activeTab === 1 ? 0 : -1"
:class="{ 'bg-orange-50 text-orange-600': activeTab === 1 }"
class="flex items-center h-10 px-6 py-2 text-sm font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-orange-500"
role="tab"
id="tab-2"
aria-controls="panel-2"
>
Biling
</button>
</li>
</ul>
<!-- Panels -->
<div
class="mt-2 overflow-hidden bg-background border rounded-b-md rounded-xl border-zinc-50"
>
<!-- Panel 1 -->
<section
x-show="activeTab === 0"
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
class="p-8"
>
Content 1
</section>
<!-- Panel 2 -->
<section
x-show="activeTab === 1"
role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
class="p-8"
>
Content 2
</section>
</div>
</div>You can try the Alpine version separately: live demo and source code.
Which one should you use?
Both persist the same way: one index in localStorage. If Alpine is already loaded, the x-data version needs no separate script and keeps the logic next to the markup; otherwise the vanilla version is a compact, dependency-free script.
Conclusion
In this tutorial we built persistent tabs twice, using the localStorage API to store and retrieve the active tab so users never lose their place.
Hope you enjoyed this tutorial and have a great day!
/Michael Andreuzza