Using Tailwind CSS and Vue to Create Animated and Accessible Tabs
Tags: tailwindcss, vue, webdev, tutorial
Published: 2023-07-19 09:03:20

####**[Live Demo](https://cruip-tutorials-vue.vercel.app/unconventional-tabs/) / [Download](https://github.com/cruip/cruip-tutorials-vue/blob/main/src/components/UnconventionalTabs.vue)**

--

Here we are to the third and final part of our series on **Using Tailwind CSS and to Create Animated and Accessible Tabs**. If you’ve read the first and second parts, you’re already familiar with the most typical use cases for tabs and how we at Cruip are big fans of this component for displaying multiple kinds of information. For example:

*   Text variations on cards on this Startup landing page template
*   Alternating screenshots on this Elegant HTML website template
*   Image variations on this Dark Next.js landing page template

You can find plenty of additional examples in our gallery of premium [Tailwind CSS templates](https://cruip.com) or [free Tailwind CSS landing pages](https://cruip.com/free-templates/), websites, and dashboards.

Let’s create a tabs component using **Tailwind CSS** and **Vue**, with comprehensive **TypeScript** compatibility, similar to [](https://cruip.com/using-tailwind-css-and-next-js-to-create-animated-and-accessible-tabs/)what we previously did for Next.js.

Creating the single component file
----------------------------------

Let’s begin by creating a new file for the component, named `UnconventionalTabs.vue`. As usual, we will use TypeScript and the `script setup` syntax to leverage the **Composition API** within the **Single-File Component (SFC)**.

```ts
  <script setup lang="ts">
  import Tab01 from '../assets/tabs-image-01.jpg'
  import Tab02 from '../assets/tabs-image-02.jpg'
  import Tab03 from '../assets/tabs-image-03.jpg'
  interface Tab {
    title: string
    img: string
    tag: string
    excerpt: string
    link: string
  }
  const tabs: Tab[] = [
    {
      title: 'Lassen Peak',
      img: Tab01,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Mount Shasta',
      img: Tab02,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Eureka Peak',
      img: Tab03,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
  ]
  </script>
  <template></template>
```
To start, we need to import the images we will use for the tabs and define the tab’s `interface`. Each tab will have a `title`, an `image`, a `tag`, an `excerpt`, and a `link`.

Next, we will use the `v-for` directive to iterate over this array of tabs and generate the HTML for each **tab** and **tab panel**.

Adding the markup
-----------------

In the previous tutorial, we demonstrated how to create an accessible tabs component in React using the Headless UI library. We’ll follow a similar approach for the Vue component, which eliminates the need for manual handling of **ARIA roles and attributes**.

Headless UI provides the same set of components for both React and Vue, although the syntax may vary slightly in some aspects. Let’s see how to achieve the exact equivalent of the Next.js component in Vue:

```ts
  <script setup lang="ts">
  import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
  import Tab01 from '../assets/tabs-image-01.jpg'
  import Tab02 from '../assets/tabs-image-02.jpg'
  import Tab03 from '../assets/tabs-image-03.jpg'
  interface Tab {
    title: string
    img: string
    tag: string
    excerpt: string
    link: string
  }
  const tabs: Tab[] = [
    {
      title: 'Lassen Peak',
      img: Tab01,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Mount Shasta',
      img: Tab02,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Eureka Peak',
      img: Tab03,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
  ]
  </script>
  <template>
    <TabGroup v-slot="{ selectedIndex }">
      <!-- Buttons -->
      <div class="flex justify-center">
        <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">      
          <Tab :key="index" v-for="(tab, index) in tabs" as="template">
            <button
              class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
              :class="selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
            >{{ tab.title }}</button>          
          </Tab>
        </TabList>
      </div>
      <!-- Tab panels -->
      <TabPanels class="max-w-[640px] mx-auto">
        <div class="relative flex flex-col">
          <TabPanel
            :key="index"
            v-for="(tab, index) in tabs"
            class="focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
            :class="selectedIndex !== index ? 'order-first' : ''"
          >
            <article class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch">        
              <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="tab.img" :alt="tab.img" />
              </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">{{ tab.tag }}</div>
                    <h1 class="text-xl font-bold text-slate-900">{{ tab.title }}</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">{{ tab.excerpt }}</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="tab.link">Read more -&gt;</a>
                </div>
              </div>
            </article>        
          </TabPanel>
        </div>
      </TabPanels>
    </TabGroup>  
  </template>
```

What we’ve made so far is a functional tabs component that that is fully accessible. Now, we want to add transitions when switching between panels. To do this, we will use another component from Headless UI: the [Transition component](https://headlessui.com/vue/transition).

Adding transition effects
-------------------------

Let’s import `TransitionRoot` from the external library, and use it inside our `TabPanel` component as follows:

```ts
  <script setup lang="ts">
  import { TabGroup, TabList, Tab, TabPanels, TabPanel, TransitionRoot } from '@headlessui/vue'
  import Tab01 from '../assets/tabs-image-01.jpg'
  import Tab02 from '../assets/tabs-image-02.jpg'
  import Tab03 from '../assets/tabs-image-03.jpg'
  interface Tab {
    title: string
    img: string
    tag: string
    excerpt: string
    link: string
  }
  const tabs: Tab[] = [
    {
      title: 'Lassen Peak',
      img: Tab01,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Mount Shasta',
      img: Tab02,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Eureka Peak',
      img: Tab03,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
  ]
  </script>
  <template>
    <TabGroup v-slot="{ selectedIndex }">
      <!-- Buttons -->
      <div class="flex justify-center">
        <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">      
          <Tab :key="index" v-for="(tab, index) in tabs" as="template">
            <button
              class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out"
              :class="selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'"
            >{{ tab.title }}</button>          
          </Tab>
        </TabList>
      </div>
      <!-- Tab panels -->
      <TabPanels class="max-w-[640px] mx-auto">
        <div class="relative flex flex-col">
          <TabPanel
            :key="index"
            v-for="(tab, index) in tabs"
            :static="true"
            class="focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
            :class="selectedIndex !== index ? 'order-first' : ''"
          >
            <TransitionRoot
              as="article"
              :show="selectedIndex === index"
              class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch"
              enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform"
              enterFrom="opacity-0 -translate-y-8"
              enterTo="opacity-100 translate-y-0"
              leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute"
              leaveFrom="opacity-100 translate-y-0"
              leaveTo="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="tab.img" :alt="tab.img" />
              </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">{{ tab.tag }}</div>
                    <h1 class="text-xl font-bold text-slate-900">{{ tab.title }}</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">{{ tab.excerpt }}</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="tab.link">Read more -&gt;</a>
                </div>
              </div>
            </TransitionRoot>        
          </TabPanel>
        </div>
      </TabPanels>
    </TabGroup>  
  </template>
```

In the `TransitionRoot` component, we have defined the `as="article"` property to render the element as an `<article>` tag. Then, we have used Tailwind CSS classes to handle the enter and exit transitions, as we did in the previous tutorials. Additionally, we’ve utilized the `selectedIndex` slot prop – provided by the `TabGroup` component – to display only the selected panel based on the index in the `tabs` array. In simpler terms, if the panel index matches the selected index, we will show the panel; otherwise, we will hide it.

To ensure the transitions function correctly, it’s important to set the `:static="true"` prop must be set on the `TabPanel`, making it static. This means that the `TabPanel` element will ignore the `selectedIndex` prop, leaving the `TransitionRoot` component responsible for handling the transition.

Modifying the component to make it reusable
-------------------------------------------

Now let’s move on to the final step, which is making our component reusable. Instead of defining the data within the component itself, we will pass it as props. This way, we will be able to use the component multiple times with different content.

To achieve this, we will move the `tabs` array to the parent component, along with the image imports. The parent component will then look like this:

```ts
  <script setup lang="ts">
  import Tab01 from '../assets/tabs-image-01.jpg'
  import Tab02 from '../assets/tabs-image-02.jpg'
  import Tab03 from '../assets/tabs-image-03.jpg'
  import UnconventionalTabs from '../components/UnconventionalTabs.vue'
  const tabs = [
    {
      title: 'Lassen Peak',
      img: Tab01,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Mount Shasta',
      img: Tab02,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },
    {
      title: 'Eureka Peak',
      img: Tab03,
      tag: 'Mountain',
      excerpt: "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.",
      link: '#0'
    },    
  ]
  </script>
  <template>
    <main class="relative min-h-screen flex flex-col justify-center bg-white overflow-hidden">
      <div class="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
        <UnconventionalTabs :tabs="tabs" />
      </div>
    </main>
  </template>
```

Now that we have moved the data to the parent component, we can safely remove it from our component. Finally, we need to use the `defineProps` compiler macro to define the props that we will receive from the parent component.

```ts
  const props = defineProps<{
    tabs: Tab[]
  }>()
  const tabs = props.tabs
```

There’s nothing else to add! We have completed our component, and the final result is as follows:

```ts
  <script setup lang="ts">
  import { TabGroup, TabList, Tab, TabPanels, TabPanel, TransitionRoot } from '@headlessui/vue'
  interface Tab {
    title: string
    img: string
    tag: string
    excerpt: string
    link: string
  }
  const props = defineProps<{
    tabs: Tab[]
  }>()
  const tabs = props.tabs
  </script>
  <template>
    <TabGroup v-slot="{ selectedIndex }">
      <!-- Buttons -->
      <div class="flex justify-center">
        <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">
          <Tab :key="index" v-for="(tab, index) in tabs" as="template">
            <button class="flex-1 text-sm font-medium h-8 px-4 rounded-2xl whitespace-nowrap focus-visible:outline-none ui-focus-visible:outline-none ui-focus-visible:ring ui-focus-visible:ring-indigo-300 transition-colors duration-150 ease-in-out" :class="selectedIndex === index ? 'bg-white text-slate-900' : 'text-slate-600 hover:text-slate-900'">{{ tab.title }}</button>
          </Tab>
        </TabList>
      </div>
      <!-- Tab panels -->
      <TabPanels class="max-w-[640px] mx-auto">
        <div class="relative flex flex-col">
          <TabPanel :key="index" v-for="(tab, index) in tabs" :static="true" class="focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300" :class="selectedIndex !== index ? 'order-first' : ''">
            <TransitionRoot as="article" :show="selectedIndex === index" class="w-full bg-white rounded-2xl shadow-xl min-[480px]:flex items-stretch" enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 transform" enterFrom="opacity-0 -translate-y-8" enterTo="opacity-100 translate-y-0" leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-300 transform absolute" leaveFrom="opacity-100 translate-y-0" leaveTo="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="tab.img" :alt="tab.img" />
              </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">{{ tab.tag }}</div>
                    <h1 class="text-xl font-bold text-slate-900">{{ tab.title }}</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">{{ tab.excerpt }}</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="tab.link">Read more -&gt;</a>
                </div>
              </div>
            </TransitionRoot>
          </TabPanel>
        </div>
      </TabPanels>
    </TabGroup>
  </template>
```

Conclusions
-----------

As we saw in the previous tutorials, you can use tabs across every part of your landing page, marketing site, or application to arrange information attractively and elegantly.

If you wanto to learn how to build this component in Alpine.js and Next.js, check out the first and second parts: