How to build feature tabs with Tailwind CSS and Alpine.js
Create an interactive feature grid with Alpine.js tabs, Tailwind CSS styling, and accessible state management.
A quick Alpine.js tab experience
Feature tabs are perfect when you want to highlight several value props without sending people to another page. In this walkthrough we will rebuild the component shown below: four cards on the left that switch a hero mockup on the right. Everything is powered by a tiny Alpine.js state object and a handful of Tailwind CSS utility classes.
We will:
- Set up the grid and add a
x-data
store for the active tab - Wire the tab buttons with keyboard-friendly state changes
- Render the matching panel with Alpine’s
x-show
- Finish with optional polish like
x-transition
andx-cloak
Prerequisites: Tailwind CSS and Alpine.js should already be available in your project. If you are following along in Astro, drop the snippet inside any component or page and it will just work.
1. Define the Alpine state
Start with a tiny helper function that keeps track of the active tab and exposes a couple of helper methods. Dropping this near the component (or in a shared script file) keeps the template clean.
<script>
function featureTabs() {
return {
tab: "tab1",
tabs: [
{
id: "tab1",
title: "Innovative Design",
copy:
"Our cutting-edge design offers a fresh, modern look that transforms your project into a standout experience.",
image: "/images/phone4.png",
},
{
id: "tab2",
title: "Powerful Functionality",
copy:
"Equipped with advanced features and tools, our solution effortlessly manages complex tasks and workflows.",
image: "/images/phone2.png",
},
{
id: "tab3",
title: "Easy Integration",
copy:
"Integrating with existing systems is smooth and hassle-free, thanks to our incredibly flexible approach.",
image: "/images/phone3.png",
},
{
id: "tab4",
title: "Great Communication",
copy:
"Keep every stakeholder in the loop with streamlined communication and instant status updates.",
image: "/images/phone5.png",
},
],
select(id) {
this.tab = id;
},
isActive(id) {
return this.tab === id;
},
};
}
</script>
Each tab entry stores the label, supporting copy, and the mockup image. You can extend this object with icons, links, or analytics metadata without touching the template again.
2. Set up the responsive grid
Wrap the component with x-data="featureTabs()"
so Alpine can share state between the tabs and the content panel. The grid uses Tailwind’s responsive utilities to stack on mobile and split into two columns on larger screens.
<div
x-data="featureTabs()"
x-cloak
class="relative grid grid-cols-1 items-center gap-4 lg:grid-cols-3"
>
<!-- Tabs will sit here -->
<!-- Preview panel will render on the right -->
</div>
The x-cloak
attribute hides the component until Alpine finishes hydrating, preventing the wrong panel from flashing during load. Make sure you have the global helper so anything with x-cloak
stays hidden:
<style>
[x-cloak] {
display: none !important;
}
</style>
3. Build the tab buttons
Each button toggles the active tab and gets a subtle style boost when selected. We also expose keyboard accessibility by using real <button>
elements and Tailwind focus styles.
<ul class="grid gap-4 list-none md:grid-cols-2 lg:col-span-2">
<template x-for="item in tabs" :key="item.id">
<li class="p-1 rounded-xl bg-zinc-50 dark:bg-zinc-800">
<button
type="button"
@click="select(item.id)"
@keydown.enter.prevent="select(item.id)"
@keydown.space.prevent="select(item.id)"
class="h-full w-full rounded-lg bg-white p-4 text-left shadow transition dark:bg-zinc-800 dark:shadow-zinc-900/80 lg:p-8"
:class="{
'bg-white dark:bg-zinc-900 shadow-lg ring-2 ring-zinc-200 dark:ring-zinc-700': isActive(item.id)
}"
>
<div class="flex items-start gap-3">
<span
class="inline-flex size-8 items-center justify-center rounded-full bg-accent/10 text-accent"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z"
stroke-width="0"
fill="currentColor"
></path>
</svg>
</span>
<div>
<h3
class="text-base font-semibold text-zinc-900 transition dark:text-white"
x-text="item.title"
></h3>
<p
class="mt-2 text-sm font-medium text-zinc-500 dark:text-zinc-400"
x-text="item.copy"
></p>
</div>
</div>
</button>
</li>
</template>
</ul>
A few notable touches:
@keydown.enter
and@keydown.space
mirror the click handler so keyboard users get the same experience.- The
:class
binding paints the active tab with a thicker shadow and dark-mode variation. - Icons stay identical across tabs, but you can add icons per tab by extending the data object.
4. Show the matching panel
On the right column we loop over the same tab data and display the correct mockup with x-show
. Adding x-transition
gives a subtle fade between panels.
<div class="size-full lg:col-span-1">
<template x-for="item in tabs" :key="item.id">
<div
class="size-full"
x-show="isActive(item.id)"
x-transition.opacity.duration.200ms
>
<div
class="aspect-square w-full rounded-3xl bg-zinc-50 p-8 pb-0 dark:bg-zinc-900"
>
<img
class="size-full object-cover object-top"
:src="item.image"
:alt="`${item.title} preview`"
/>
</div>
</div>
</template>
</div>
Because the panels share the same structure, we rely on Alpine’s looping to avoid repeating markup. x-transition
prevents abrupt content swaps, especially noticeable with darker themes.
5. Accessibility and UX tweaks
Keep the component inclusive and smooth by applying a few best practices:
- Add
role="tablist"
,role="tab"
, androle="tabpanel"
if your design calls for strict WAI-ARIA semantics (the example above uses a card-style pattern, so regular buttons + headings are acceptable). - Use
aria-pressed="isActive(item.id)"
oraria-selected
when necessary so screen readers announce the current tab. - Persist the last active tab in
localStorage
if you want the UI to remember the visitor’s choice. - For large image assets, consider adding
loading="lazy"
or preloading the next slide for a snappier experience.
Full component snippet
Here’s the entire HTML block that you can drop into an Astro component (or any HTML file) after including Alpine.js and Tailwind CSS.
<style>
[x-cloak] {
display: none !important;
}
</style>
<script>
function featureTabs() {
return {
tab: "tab1",
tabs: [
{
id: "tab1",
title: "Innovative Design",
copy:
"Our cutting-edge design offers a fresh, modern look that transforms your project into a standout experience.",
image: "/images/phone4.png",
},
{
id: "tab2",
title: "Powerful Functionality",
copy:
"Equipped with advanced features and tools, our solution effortlessly manages complex tasks and workflows.",
image: "/images/phone2.png",
},
{
id: "tab3",
title: "Easy Integration",
copy:
"Integrating with existing systems is smooth and hassle-free, thanks to our incredibly flexible approach.",
image: "/images/phone3.png",
},
{
id: "tab4",
title: "Great Communication",
copy:
"Keep every stakeholder in the loop with streamlined communication and instant status updates.",
image: "/images/phone5.png",
},
],
select(id) {
this.tab = id;
},
isActive(id) {
return this.tab === id;
},
};
}
</script>
<div
x-data="featureTabs()"
x-cloak
class="relative grid grid-cols-1 items-center gap-4 lg:grid-cols-3"
>
<ul
class="grid gap-4 list-none md:grid-cols-2 lg:col-span-2"
role="tablist"
aria-orientation="horizontal"
>
<template x-for="item in tabs" :key="item.id">
<li class="rounded-xl bg-zinc-50 p-1 dark:bg-zinc-800">
<button
type="button"
role="tab"
:aria-selected="isActive(item.id)"
:aria-controls="`${item.id}-panel`"
@click="select(item.id)"
@keydown.enter.prevent="select(item.id)"
@keydown.space.prevent="select(item.id)"
class="h-full w-full rounded-lg bg-white p-4 text-left shadow transition dark:bg-zinc-800 dark:shadow-zinc-900/80 lg:p-8"
:class="{
'bg-white dark:bg-zinc-900 shadow-lg ring-2 ring-zinc-200 dark:ring-zinc-700': isActive(item.id)
}"
>
<div class="flex items-start gap-3">
<span
class="inline-flex size-8 items-center justify-center rounded-full bg-accent/10 text-accent"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z"
stroke-width="0"
fill="currentColor"
></path>
</svg>
</span>
<div>
<h3
class="text-base font-semibold text-zinc-900 transition dark:text-white"
x-text="item.title"
></h3>
<p
class="mt-2 text-sm font-medium text-zinc-500 dark:text-zinc-400"
x-text="item.copy"
></p>
</div>
</div>
</button>
</li>
</template>
</ul>
<div class="size-full lg:col-span-1">
<template x-for="item in tabs" :key="item.id">
<div
class="size-full"
x-show="isActive(item.id)"
x-transition.opacity.duration.200ms
role="tabpanel"
:id="`${item.id}-panel`"
:aria-labelledby="item.id"
>
<div class="aspect-square w-full rounded-3xl bg-zinc-50 p-8 pb-0 dark:bg-zinc-900">
<img
class="size-full object-cover object-top"
:src="item.image"
:alt="`${item.title} preview`"
/>
</div>
</div>
</template>
</div>
</div>
Where to go next
- Swap the static copy for dynamic data pulled from an Astro content collection.
- Add analytics tracking inside the
select
method to learn which feature resonates most. - Extend the tabs array with a
cta
property if you want to render a button inside each panel.
With Alpine.js and Tailwind CSS you can build delightful, high-converting UI patterns using nothing but HTML attributes and a sprinkling of JavaScript. Happy shipping!
/Michael Andreuzza