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.

Published on October 1, 2025 by Michael Andreuzza

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 and x-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", and role="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)" or aria-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

Did you like this post? Please share it with your friends!