tutos

How to build a filterable list with Nuxt and Tailwind

Thursday 2nd February, 2023 —

Getting started

As we all know, starting a new framework can be overwhelming and it's easy to feel lost in a sea of documentation. However, it's important to remember that we've all been new to Nuxt and Vue at some point in time. That being said, in this blog post, we will dive into the process of building a filterable list from scratch, and by the end of it, you will have a clear understanding of how to bring this functionality to your next Nuxt and Vue project. So, whether you're a seasoned developer or just starting out, let's get started!

This is what we'll be building today.

Nuxt Filterable List

Installing Nuxt

This tutorial is not about Nuxt itself. If you want to learn more about Nuxt, take the time to go through their documentation. We are going to use stackblitz.com to display this tutorial source code, but we'll proceed as if you were installing Nuxt on your machine to start a local project. Open a terminal window, change directory to where you want to install your project and start by installing Nuxt:

pnpm dlx nuxi init <project-name>

This will install nuxt and required files inside the folder of your choice, represented by the <project-name> abstraction. Once the installation is done, change directory to your project by running cd /project-name. You should now install the project dependencies:

pnpm install

Once your installation is finished, you are now ready to launch the project. Make sure everything is set properly by running the project:

pnpm dev

You project should now be running properly. Hit Ctrl + C in your terminal to stop the dev server. We still need to add Tailwind CSS.

Installing Tailwind CSS

Tailwind CSS is a utility first framework growing more and more in popularity. Tailwind requires a bit of CSS knowledge to be efficient. If you're not familiar with Tailwind and its features, I strongly recommend you go through their documentation.

Tailwind CSS is a standalone library that can be installed in Nuxt using the module system. Let's say, to remain simple that Nuxt modules act the same way as javascript plugins. Install the Nuxt Tailwind CSS and the nuxt-icon (we are also going to need this) modules in the project:

pnpm i -D @nuxtjs/tailwindcss nuxt-icon

When the installation is done, open your Nuxt configuration file: ./nuxt.config.ts, and add the modules that we've just installed to it:

modules: ['@nuxtjs/tailwindcss', 'nuxt-icon']

Tailwind CSS is a library that ships with a configuration file that you can use to alter some default behaviours. Start by creating a tailwind.config.cjs file at the root of the project. Inside that file paste the following:

module.exports = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
      },
    },
  },
}

To keep things simple, we are using the vanilla Tailwind CSS installation without any customizations. We simply extend the theme configuration to use a different font family, which is the Inter font.

:: tip Please note that the Inter font won't be loaded by tailwind and should be loaded manually. ::

We now have Nuxt and Tailwind CSS both installed. Let's start coding our contact list.

Preparing the layout

The main entry point of a Nuxt app is the app.vue file located at the root of the project. Nuxt has very vast capabilities, being able to handle layouts and pages. Since our project is very simple, we won't need all of that since we'll be exclusively working inside app.vue.

Inside the app.vue file, remove the welcome component and add a some divs that will act as our layout inside the <template></template> element.

<template>
  <!-- Wrapper -->
  <div class="min-h-screen w-full bg-slate-100">
    <!-- Container -->
    <div class="w-full max-w-5xl mx-auto py-24 px-4"></div>
  </div>
</template>

We also need some kind of title or heading for our page. Let's add one inside the container element:

<template>
  <div class="text-center">
    <h1 class="mb-2 font-sans text-4xl font-bold text-slate-800">
      Contact List
    </h1>
    <p class="font-sans text-lg text-slate-500">
      A filterable contact list made with Nuxt 3 and Tailwind CSS
    </p>
  </div>
</template>

Let's add a wrapper for our future contact list, just below the title we've just added:

<template>
  <!-- List wrapper -->
  <div class="relative">
    <div class="relative grid sm:grid-cols-3 gap-6"></div>
  </div>
</template>

We now have what we need to start rendering data.

Creating interfaces

To be able to render a list in Vue / Nuxt, We need a list. Like stated by this tutorial title, we want to render a list of contacts. This is the perfect occasion to introduce some basic Typescript features to those of you who are not familiar with it. Typescript is often referred as scary, but it's just a myth. Typescript is easy to learn and provides an incredible code and error support when developping. We won't dive into the most complex concepts of Typescript as we simply need one of its basic features in our tutorial: interfaces.

First, start by opening a vue script tag, using the Composition API syntax:

<script setup lang="ts"></script>

Javascript is known as weakly typed language, which can cause some problemes when working with complex data structures. We are going to use Typescript to tell our app which kind of data it should expect. Since we are building a contact list, let's define first a contact interface inside the script tag we've just created:

<script setup lang="ts">
  interface Contact {
    id: number
    name: string
    email: string
    role: string
    photo: string
  }
</script>

For each property our contact model has, we use Typescript to tell our browser which data types he'll be resolving. Nowadays, contacts have social profiles. A social profile is a more complex data type than a simple string or number. We also need to create an interface for that:

interface SocialAccount {
  name: string
  url: string
  icon: string
}

We now need to nest this interface in the first one, so a contact can have a social profile:

interface Contact {
  id: number
  name: string
  email: string
  role: string
  photo: string
  socialAccount: SocialAccount
}

But wait, some of you are probably already thinking that one might have more than a single social account, and they are right. Let's update our interface and tell Typescript to wait for an array of social profiles rather than a single one:

interface Contact {
  id: number
  name: string
  email: string
  role: string
  photo: string
  socialAccounts: SocialAccount[]
}

Rendering a list of contacts

Amazing! We now have our Typescript interfaces and we are ready to throw in some demo data to implement our list rendering. Let's start by creating an empty array that will hold our contacts. Below the Typescript interfaces we've previously added, add a new empty array using the Vue shallowRef function. To remain simple, we can say that shallowRef() optimizes performance when storing large datasets instead of using ref(), which watches changes on all nested children. It is generally a good practice to use it when you don't need deeply nested reactivity:

const contacts = shallowRef([])

Let's use the interfaces we've created to type this array, and therefore be warned by Typescript if we start messing around with the data types we've created. The array is a list of contacts. They should be bound to the Contact interface. Here is how you do that:

const contacts = shallowRef<Contact[]>([])

Notice the '' bracket after the 'Contact' type, telling Typescript to expect an array based off that type. Let's start adding our demo data (refer to stackblitz for the full list of fake contacts):

<script setup lang="ts">
  const contacts = shallowRef<Contact[]>([
    {
      id: 1,
      name: "Jacob Lee",
      email: "jacob@gmail.com",
      role: "Head of Operations",
      photo: "https://media.cssninja.io/shuriken/avatars/16.svg",
      socialAccounts: [
        {
          name: "Twitter",
          url: "https://twitter.com/cssninja",
          icon: "fa6-brands:twitter",
        },
        {
          name: "Facebook",
          url: "https://facebook.com/cssninja",
          icon: "fa6-brands:facebook-f",
        },
        {
          name: "Linkedin",
          url: "https://instagram.com/cssninja",
          icon: "fa6-brands:linkedin-in",
        },
      ],
    },
    /* ... */
  ])
</script>

We now have everything we need to start rendering our contact list in the UI. In order to do that, we are going to iterate on that list using a special vue built-in directive called v-for. This directive always uses the same syntax:

<div v-for="item in items" :key="item.name">
  {{ item.name }}
</div>

where items is the data passed in, item the current element, and key a unique identifier pointing to item (You can learn more about using keys in Vue in this interesting video). Let's use that same logic for our contact list and style our items using Tailwind CSS classes:

<template>
  <!-- List wrapper -->
  <div class="relative mt-10">
    <div class="relative grid sm:grid-cols-3 gap-6">
      <!-- Contact -->
      <div
        v-for="contact in contacts"
        :key="contact.name"
        class="font-sans border border-slate-200 p-6 rounded-xl bg-white hover:shadow-xl hover:shadow-slate-300/20 transition-all duration-300"
      >
        <!-- Photo -->
        <div class="flex justify-center">
          <img
            class="w-20 h-20 rounded-full shrink-0"
            :src="contact.photo"
            :alt="contact.name"
          />
        </div>
        <!-- Name -->
        <div class="text-center mt-4">
          <h4 class="text-lg font-medium text-slate-700">
            {{ contact.name }}
          </h4>
          <p class="text-sm text-slate-400">{{ contact.role }}</p>
          <a
            :href="`mailto:${contact.email}`"
            class="flex items-center justify-center gap-2 py-4 text-sm underline-offset-4 text-slate-500 hover:text-violet-500 hover:underline transition-colors duration-300"
          >
            <Icon name="lucide:mail" class="w-4 h-4" />
            <span>{{ contact.email }}</span>
          </a>
        </div>
        <!-- Social accounts -->
        <div class="flex justify-center gap-2 my-4">
          <a
            v-for="socialAccount in contact.socialAccounts"
            :key="socialAccount.name"
            :href="socialAccount.url"
            class="h-8 w-8 flex items-center justify-center rounded-full bg-slate-100 text-slate-400 hover:bg-violet-100 hover:text-violet-600 transition-colors duration-300"
          >
            <Icon :name="socialAccount.icon" class="w-3 h-3" />
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

Everything should look great, you've now been able to render your contact list successfully, but we're not done yet. Let's add filtering to the list.

Adding a search filter

We want to filter our contact list base on their name, role and email. For that, we first need to add an input element to our UI. Below the title, let's add a search input:

<template>
  <!-- Search -->
  <div class="relative w-full max-w-xs mx-auto flex mt-10 mb-20">
    <input
      type="text"
      class="peer w-full h-12 inline-flex items-center pl-12 pr-4 rounded-xl border border-slate-300 text-slate-700 placeholder:text-slate-300 font-sans leading-snug outline-none focus-visible:outline-dashed focus-visible:outline-offset-4 focus-visible:outline-slate-300"
      placeholder="Filter contacts..."
    />
    <div
      class="absolute top-0 left-0 h-12 w-12 flex items-center justify-center text-slate-400 peer-focus:text-violet-500 transition-colors duration-300"
    >
      <Icon name="lucide:search" class="w-5 h-5" />
    </div>
  </div>
</template>

Right now, our search filter does nothing. We need to use another special vue directive to bind this input to a piece of data. Let's use the v-model directive. Before using v-model, we need to create a variable that will be used by it. Inside the script tag, add a new Ref, which is a Vue 3 Composition API feature that allows you to handle reactive values in a very simple way (learn more about Vue Ref in the official documentation):

<script setup lang="ts">
  const filter = ref("");
</script>

Let's then bind that ref to our search input using v-model:

<template>
  <!-- Search -->
  <div class="relative w-full max-w-xs mx-auto flex mt-10 mb-20">
    <input
      v-model="filter"
      type="text"
      class="peer w-full h-12 inline-flex items-center pl-12 pr-4 rounded-xl border border-slate-300 text-slate-700 placeholder:text-slate-300 font-sans leading-snug outline-none focus-visible:outline-dashed focus-visible:outline-offset-4 focus-visible:outline-slate-300"
      placeholder="Filter contacts..."
    />
    <div
      class="absolute top-0 left-0 h-12 w-12 flex items-center justify-center text-slate-400 peer-focus:text-violet-500 transition-colors duration-300"
    >
      <Icon name="lucide:search" class="w-5 h-5" />
    </div>
  </div>
</template>

Now that we have our v-model, we are ready to implement our filtering behaviour. For that, we are going to use a special Vue Composition API method called computed(). This function is going to process our contacts array based on the content of our search filter. If it is empty, it returns the entire array, if not, it returns a collection of filtered objects. Let's add this function inside our script tag, below our filter Ref:

<script setup lang="ts">
const filter = ref('')

const filteredContacts = computed(() => {
  if (!filter.value) {
    return contacts.value
  }

  const filterRe = new RegExp(filter.value, 'i')
  return contacts.value.filter((item) => {
    return [item.name, item.email, item.role].some((item) =>
      item.match(filterRe)
    )
  })
})
</script>

Let's also sort this list of contacts. Let's add this function inside our script tag, below our filteredContacts computed:

<script setup lang="ts">
const filter = ref('')

const filteredContacts = computed(() => {
  if (!filter.value) {
    return contacts.value
  }

  const filterRe = new RegExp(filter.value, 'i')
  return contacts.value.filter((item) => {
    return [item.name, item.email, item.role].some((item) =>
      item.match(filterRe)
    )
  })
})

const sortedContacts = computed(() => {
  return filteredContacts.value.sort((a, b) => {
    return a.name.localeCompare(b.name)
  })
})
</script>

We now have a new variable named sortedContacts that contains our filtered and sorted array. The last step is to replace the reference to the initial contacts array in our template by our new sortedContacts:

<template>
  <!-- List wrapper -->
  <div class="relative mt-10">
    <div class="relative grid sm:grid-cols-3 gap-6">
      <!-- Contact -->
      <div
        v-for="contact in sortedContacts"
        :key="contact.name"
        class="font-sans border border-slate-200 p-6 rounded-xl bg-white hover:shadow-xl hover:shadow-slate-300/20 transition-all duration-300"
      >
        <!-- Photo -->
        <div class="flex justify-center">
          <img
            class="w-20 h-20 rounded-full shrink-0"
            :src="contact.photo"
            :alt="contact.name"
          />
        </div>
        <!-- Name -->
        <div class="text-center mt-4">
          <h4 class="text-lg font-medium text-slate-700">
            {{ contact.name }}
          </h4>
          <p class="text-sm text-slate-400">{{ contact.role }}</p>
          <a
            :href="`mailto:${contact.email}`"
            class="flex items-center justify-center gap-2 py-4 text-sm underline-offset-4 text-slate-500 hover:text-violet-500 hover:underline transition-colors duration-300"
          >
            <Icon name="lucide:mail" class="w-4 h-4" />
            <span>{{ contact.email }}</span>
          </a>
        </div>
        <!-- Social accounts -->
        <div class="flex justify-center gap-2 my-4">
          <a
            v-for="socialAccount in contact.socialAccounts"
            :key="socialAccount.name"
            :href="socialAccount.url"
            class="h-8 w-8 flex items-center justify-center rounded-full bg-slate-100 text-slate-400 hover:bg-violet-100 hover:text-violet-600 transition-colors duration-300"
          >
            <Icon :name="socialAccount.icon" class="w-3 h-3" />
          </a>
        </div>
      </div>
    </div>
  </div>
</template>

Start typing in the filter input and you'll see that the list is reacting to what you type. Awesome isn't it? Though, we still need to handle a particular edge case. Let's say your search doesn't return any results. You'll simply be shown a blank area with nothing inside it. To polish our experience, we still need to inform the user that no matching results were found. We are going to use a UI placeholder and a bit of logic for that. We are going to use some other built-in directives used to express if/else statements: v-if and v-else. The first one requires a condition to be met while the second one acts as a fallback.

Let's add a placeholder just above our contact grid and set certain conditions:

<template>
  <!-- List wrapper -->
  <div class="relative">
    <!-- Placeholder -->
    <div v-if="sortedContacts.length === 0">
      <div
        class="text-center max-w-sm mx-auto flex items-center justify-center py-24"
      >
        <div>
          <Icon name="et:layers" class="w-12 h-12 mb-4 text-slate-400" />
          <h3 class="font-sans text-lg text-slate-700">No contacts found</h3>
          <p class="text-sm text-slate-400">
            We couldn't find any contacts matching your search criteria. Please
            try another search.
          </p>
        </div>
      </div>
    </div>
    <div v-else class="relative grid sm:grid-cols-3 gap-6">
      <!-- v-for results -->
    </div>
  </div>
</template>

What happens here? We tell Vue to display our placeholder if no matching elements are found (e.g the array is empty) and to display our grid if the array contains some items. Try typing random letters in your search input to see the placeholder appear.

Polish and finish

Looks like we're done. We could completely stop here as we have a nice contact list, the ability to filter it, and even a placeholder to display when there are no matching results. However, we still can add a nice and fancy effect to make this list even more amazing. Let's try to apply a transition to our list elements when they change. For that we are going to use a special vue component called <TransitionGroup />. We'll go over it real quick as it is not the main focus of this tutorial, but you can learn more about it on the official vue documentation. Let's wrap our v-for element with a transition group:

<template>
  <!-- List wrapper -->
  <div class="relative">
    <!-- Placeholder -->
    <div v-if="filteredContacts.length === 0">
      <div
        class="text-center max-w-sm mx-auto flex items-center justify-center py-24"
      >
        <div>
          <Icon name="et:layers" class="w-12 h-12 mb-4 text-slate-400" />
          <h3 class="font-sans text-lg text-slate-700">No contacts found</h3>
          <p class="text-sm text-slate-400">
            We couldn't find any contacts matching your search criteria. Please
            try another search.
          </p>
        </div>
      </div>
    </div>
    <div v-else class="relative grid sm:grid-cols-3 gap-6">
      <TransitionGroup
        enter-active-class="transform-gpu"
        enter-from-class="opacity-0 -translate-x-full"
        enter-to-class="opacity-100 translate-x-0"
        leave-active-class="absolute transform-gpu"
        leave-from-class="opacity-100 translate-x-0"
        leave-to-class="opacity-0 -translate-x-full"
      >
        <!-- v-for in here -->
      </TransitionGroup>
    </div>
  </div>
</template>

Start typing in your search filter again and Tada! You'll notice a subtle animation is now playing on items when you are filtering them.

Live demo & Code

All the code written for this demo is available on Stackblitz

View on Stackblitz

Epilogue

Today, we've learned a lot using Nuxt and Vue. We learned how to build a simple contact list using Typescript interfaces, rendering it using a v-for, filtering it using v-model and computed(), and even displaying an empty placeholder and some transitions. I hope you enjoyed this tutorial as much as I enjoyed writing it. We hope to hear from you about it and to see you very soon on cssninja.io.

Back to Blog

Continue reading

Server Cache Invalidation in Nuxt and Nitro

Top 10 Vue Components Libraries

How to build a filterable list with Nuxt and Tailwind