DEV Community

Cover image for Using Tailwind CSS and Vue to Create Animated and Accessible Tabs
Cruip
Cruip

Posted on • Originally published at cruip.com

Using Tailwind CSS and Vue to Create Animated and Accessible Tabs

Live Demo / Download

--

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 or free Tailwind CSS landing pages, websites, and dashboards.

Let’s create a tabs component using Tailwind CSS and Vue, with comprehensive TypeScript compatibility, similar to 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).

  <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>
Enter fullscreen mode Exit fullscreen mode

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:

  <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>
Enter fullscreen mode Exit fullscreen mode

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.

Adding transition effects

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

  <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>
Enter fullscreen mode Exit fullscreen mode

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:

  <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>
Enter fullscreen mode Exit fullscreen mode

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.

  const props = defineProps<{
    tabs: Tab[]
  }>()
  const tabs = props.tabs
Enter fullscreen mode Exit fullscreen mode

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

  <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>
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)