Summit Themes
Blog

How to create an interactive feature section with tabs using Alpine JS and Tailwind

Feature sections are one of the most common components on any service or product landing page. A static list of bullet points does the job, but a tabbed layout lets you pack far more detail into the same vertical space — and keeps visitors exploring instead of scrolling past. The good news: you can build a polished, accessible tabbed feature section with about 60 lines of HTML and no custom JavaScript file.

This tutorial walks through the whole thing using Alpine.js v3 for interactivity and Tailwind CSS v4 for styling. Both tools are deliberately minimal — Alpine gives you reactive state directly in your HTML attributes, Tailwind generates exactly the utility classes you use. The result is a component that is easy to understand, easy to maintain, and easy to drop into any Astro project.

What we are building

A feature section with three tabs — "Speed", "SEO", and "Accessibility" — each revealing a heading, a short paragraph, and a short feature list. Clicking a tab button switches the visible panel with a smooth transition. The active tab button gets a distinct style. Keyboard users can navigate between tabs using the arrow keys, and screen readers get proper role and aria-selected attributes.

Setting up Alpine.js and Tailwind CSS v4

For a quick prototype or an Astro project that already has Tailwind v4 configured, you can load Alpine from the CDN. In production, install it as a package.

CDN (quick start)

<!-- In your <head> -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
npm install alpinejs

Then import and start Alpine in your Astro layout or a client-side script:

import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

For Tailwind CSS v4, configuration moves from tailwind.config.js into your CSS file using the @theme directive. You no longer need a separate JavaScript config for custom tokens:

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.55 0.22 256);
  --color-brand-light: oklch(0.93 0.06 256);
}

With both libraries in place, you are ready to build the component.

The component structure

Alpine.js components are just regular HTML elements with an x-data attribute. That attribute holds a JavaScript object that defines your component's state. Every directive inside that element — x-show, :class, @click — can read and mutate that state reactively.

For a tab component, all you need is one piece of state: which tab is currently active. We will track it as a string matching the tab's identifier.

The full component

<section
  x-data="{ activeTab: 'speed' }"
  class="mx-auto max-w-4xl px-6 py-20"
>
  <h2 class="mb-10 text-center text-3xl font-bold tracking-tight">
    Everything your site needs, built in
  </h2>

  <!-- Tab buttons -->
  <div role="tablist" class="flex gap-2 border-b border-gray-200">

    <button
      role="tab"
      :aria-selected="activeTab === 'speed'"
      :tabindex="activeTab === 'speed' ? 0 : -1"
      @click="activeTab = 'speed'"
      @keydown.right.prevent="activeTab = 'seo'"
      @keydown.left.prevent="activeTab = 'accessibility'"
      :class="activeTab === 'speed'
        ? 'border-b-2 border-brand text-brand font-semibold'
        : 'text-gray-500 hover:text-gray-800'"
      class="-mb-px px-5 py-3 text-sm transition-colors"
    >
      Speed
    </button>

    <button
      role="tab"
      :aria-selected="activeTab === 'seo'"
      :tabindex="activeTab === 'seo' ? 0 : -1"
      @click="activeTab = 'seo'"
      @keydown.right.prevent="activeTab = 'accessibility'"
      @keydown.left.prevent="activeTab = 'speed'"
      :class="activeTab === 'seo'
        ? 'border-b-2 border-brand text-brand font-semibold'
        : 'text-gray-500 hover:text-gray-800'"
      class="-mb-px px-5 py-3 text-sm transition-colors"
    >
      SEO
    </button>

    <button
      role="tab"
      :aria-selected="activeTab === 'accessibility'"
      :tabindex="activeTab === 'accessibility' ? 0 : -1"
      @click="activeTab = 'accessibility'"
      @keydown.right.prevent="activeTab = 'speed'"
      @keydown.left.prevent="activeTab = 'seo'"
      :class="activeTab === 'accessibility'
        ? 'border-b-2 border-brand text-brand font-semibold'
        : 'text-gray-500 hover:text-gray-800'"
      class="-mb-px px-5 py-3 text-sm transition-colors"
    >
      Accessibility
    </button>

  </div>

  <!-- Tab panels -->
  <div class="mt-10">

    <div
      role="tabpanel"
      x-show="activeTab === 'speed'"
      x-transition:enter="transition ease-out duration-200"
      x-transition:enter-start="opacity-0 translate-y-2"
      x-transition:enter-end="opacity-100 translate-y-0"
    >
      <h3 class="mb-3 text-xl font-semibold">Blazing-fast page loads</h3>
      <p class="mb-6 text-gray-600">
        Every theme ships with static HTML output, zero client-side JavaScript
        by default, and pre-optimised images. You get a perfect Lighthouse
        performance score on day one.
      </p>
      <ul class="space-y-2 text-gray-700">
        <li>✓ Astro's islands architecture keeps bundles tiny</li>
        <li>✓ Images use native <code>loading="lazy"</code> and <code>srcset</code></li>
        <li>✓ Fonts preloaded and subset for the Latin character range</li>
      </ul>
    </div>

    <div
      role="tabpanel"
      x-show="activeTab === 'seo'"
      x-transition:enter="transition ease-out duration-200"
      x-transition:enter-start="opacity-0 translate-y-2"
      x-transition:enter-end="opacity-100 translate-y-0"
    >
      <h3 class="mb-3 text-xl font-semibold">Local SEO baked in</h3>
      <p class="mb-6 text-gray-600">
        Meta tags, Open Graph, JSON-LD structured data for local businesses,
        and a sitemap are all generated from a single data file. No plugin
        configuration, no forgotten <code>&lt;title&gt;</code> tags.
      </p>
      <ul class="space-y-2 text-gray-700">
        <li>✓ <code>LocalBusiness</code> JSON-LD on every page</li>
        <li>✓ Canonical URLs and hreflang auto-generated</li>
        <li>✓ Sitemap and <code>robots.txt</code> included</li>
      </ul>
    </div>

    <div
      role="tabpanel"
      x-show="activeTab === 'accessibility'"
      x-transition:enter="transition ease-out duration-200"
      x-transition:enter-start="opacity-0 translate-y-2"
      x-transition:enter-end="opacity-100 translate-y-0"
    >
      <h3 class="mb-3 text-xl font-semibold">Accessible by default</h3>
      <p class="mb-6 text-gray-600">
        Semantic HTML throughout, sufficient colour contrast ratios, skip-nav
        links, and focus-visible outlines. Tested with a screen reader before
        every release.
      </p>
      <ul class="space-y-2 text-gray-700">
        <li>✓ Focus-visible rings on all interactive elements</li>
        <li>✓ Colour contrast meets WCAG AA at minimum</li>
        <li>✓ Landmark regions and skip link included</li>
      </ul>
    </div>

  </div>
</section>

How each Alpine directive works

x-data — the component root

Placing x-data="{ activeTab: 'speed' }" on the <section> tells Alpine to treat that element and all its descendants as a single reactive component. The object you pass is plain JavaScript — you can add methods, computed values, and anything else you would put in a normal object literal. Every child directive can reference activeTab by name.

@click — updating state

@click is shorthand for x-on:click. Clicking a tab button runs the expression activeTab = 'seo', which mutates the reactive state and causes every directive that reads activeTab to re-evaluate automatically. That is the entire two-way binding model — no setState, no store subscription required for a component this size.

:class — conditional styling

:class is shorthand for x-bind:class. Alpine merges the bound object or string with the static class attribute, so you can safely mix static Tailwind classes with dynamic ones. The ternary expression in each button evaluates to a different set of utility classes depending on whether that tab is active.

x-show — toggling panels

x-show toggles display: none on an element based on the expression it receives. It does not remove the element from the DOM, which means the browser does not re-parse and re-render the panel HTML each time — it just shows or hides it. This is usually what you want for tab panels, because it preserves any scroll position or form state inside them.

x-transition — enter animations

The x-transition:enter* directives apply CSS classes during the moment a hidden element becomes visible. Here we use three stages:

  • x-transition:enter — the Tailwind transition utility, active for the whole entering phase.
  • x-transition:enter-start — the starting state (invisible, shifted down slightly).
  • x-transition:enter-end — the ending state (fully opaque, in position).

Alpine interpolates between start and end automatically via CSS. You are not writing any keyframe logic — just declaring the before and after states with utility classes.

Keyboard navigation and accessibility

The ARIA tab pattern requires that arrow keys move focus between tabs, not the Tab key (Tab should move focus in and out of the tab list as a whole). We handle this with @keydown.right and @keydown.left on each button. The .prevent modifier calls event.preventDefault() so the page does not scroll when the user presses the right arrow.

The :tabindex binding ensures only the active tab is reachable by Tab key — inactive tabs get tabindex="-1". Combined with :aria-selected and role="tab" / role="tablist" / role="tabpanel", screen readers will announce which tab is selected and navigate correctly between tabs and their associated panels.

Refactoring with Alpine.data() for reuse

If you need the same tab component in multiple places, extract the data object into Alpine.data() before Alpine initialises:

import Alpine from 'alpinejs';

document.addEventListener('alpine:init', () => {
  Alpine.data('featureTabs', (defaultTab = 'speed') => ({
    activeTab: defaultTab,
    setTab(tab) {
      this.activeTab = tab;
    },
    isActive(tab) {
      return this.activeTab === tab;
    }
  }));
});

Alpine.start();

Then reference it in HTML without any inline JavaScript:

<section x-data="featureTabs('seo')">
  <!-- same markup, but replace activeTab === 'speed' with isActive('speed') -->
  <button @click="setTab('speed')" :aria-selected="isActive('speed')">Speed</button>
  ...
</section>

This keeps your HTML declarative and moves testable logic into one JavaScript location. It also makes passing a default tab trivial — just change the argument you pass to featureTabs().

Styling the active indicator with Tailwind v4

In the example above, the active tab gets a border-b-2 border-brand underline. In Tailwind v4 the brand color is defined once in your CSS with @theme, and Tailwind generates both border-brand and text-brand (and every other scale variant) from it automatically — no extend.colors object needed:

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.55 0.22 256);
}

That single line makes bg-brand, text-brand, border-brand, ring-brand, and their opacity variants all available as utility classes. The CSS variable is also available natively as var(--color-brand) anywhere in your styles.

Putting it in an Astro component

Wrap the section in a .astro file and Alpine will just work — Astro outputs static HTML and the CDN or npm-imported Alpine script handles the client-side behaviour. No client:load directive is needed because Alpine is not an Astro integration; it is a plain script that reads the DOM on page load.

---
// src/components/FeatureTabs.astro
const tabs = [
  { id: 'speed', label: 'Speed' },
  { id: 'seo',   label: 'SEO' },
  { id: 'accessibility', label: 'Accessibility' },
];
---

<section x-data="{ activeTab: 'speed' }" class="mx-auto max-w-4xl px-6 py-20">
  <div role="tablist" class="flex gap-2 border-b border-gray-200">
    {tabs.map((tab) => (
      <button
        role="tab"
        :aria-selected={`activeTab === '${tab.id}'`}
        :tabindex={`activeTab === '${tab.id}' ? 0 : -1`}
        @click={`activeTab = '${tab.id}'`}
        :class={`activeTab === '${tab.id}'
          ? 'border-b-2 border-brand text-brand font-semibold'
          : 'text-gray-500 hover:text-gray-800'`}
        class="-mb-px px-5 py-3 text-sm transition-colors"
      >
        {tab.label}
      </button>
    ))}
  </div>
  <!-- panels slot here -->
</section>

Note the use of template literals to embed the tab.id into Alpine attribute strings — Astro renders those as static HTML attributes, and Alpine then parses and evaluates them in the browser.

Wrapping up

A tabbed feature section built this way has no runtime framework overhead, no build-time complexity beyond what you already have with Astro and Tailwind, and full keyboard and screen reader support. The entire interactive layer is a handful of HTML attributes that read almost like plain English: "when active tab equals this tab's ID, show this panel". That readability is what makes Alpine a good fit for components like this — the logic lives right next to the markup it controls, so there is nothing to hunt for when you need to change it six months later.