JavaScript in Plain English

New JavaScript and Web Development content every day. Follow to join our 3.5M+ monthly readers.

Follow publication

Creating a Tag Input Component Using the Vue 3 Composition API

Vue Tag Input built with Vue 3 Composition and Vite

Have you ever been curious about creating a stylish tag input component similar to those found in blog admin panels or applications like Notion, or for posting questions on StackOverflow? Your curiosity ends here!

In this guide, we’ll harness the power of Vue 3’s Composition API to construct our very own reusable tag input component. Along the way, we’ll explore essential concepts that will empower you to effectively utilize Vue 3’s Composition API, as well as learn how to build and publish your Vue.js library to NPM.

Looking for a ready-made feature rich and performant Vue tag input component? Checkout @mayank1513/vue-tag-input.

Please star this repo so that it will be easier for you and others to find it later. Also, it gives me encouragement and inspiration to build good stuff.

However, if your goal is to delve deeper into the Composition API and understand the process of building custom, reusable components, then keep reading!

What we will be building?

We are going to build a tag input component as shown below. Custom tags can be entered in the tag input, and when the enter key is pushed, they are committed to the input. Additionally, tags can be removed by backspacing on an empty input (i.e., an input when none are currently being written) or by clicking the small x next to each tag.

Tag Input Component

The input will also allow for the restriction of the tags to only a few values, which will display when the input field is focused and be narrowed as the user inputs. The component will be designed with these qualities in order to be reused and have an easy-to-use interface that functions similarly to a typical input element.

Scaffolding a new project with Vite

Now that we have understood what we want to build, let's get our hands dirty. Kick off to your machine and scaffold a new project with vite. You can learn more about vite here. However, for now, let’s jump into creating a new vue.js project with vite. Run the following commands to install and run vite CLI.

 npm create vite@latest vue-tag-input -- --template vue

Feel free to use yarn or pnpm if you like.

Creating our TagInput component

Create a new component. You may choose to create a directory called lib and create TagInput.vue component inside that. We will use setup script.

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

<template>
</template>

<style scoped>
</style>

Moving forward, we’ll import the ref function from Vue and employ it to store the tags currently present in our component. We can start by initializing it with an array containing some example tags, facilitating a clearer understanding of our initial setup.

import {ref} from 'vue';
const tags = ref(['hello', 'world']);

The above code snippet will go inside our <script setup lang='ts'> tag. The setup attribute instructs Vue compiler to treat the entire script as a setup function. Thus, we don’t need to create a setup function separately. lang='ts' instructs to interpret this script as TypeScript. ref function is used to create state variables, equivalent to data attribute in options API in Vue2.

Now that we have a method to keep track of the tags, our next task is to tackle the task of displaying them in the template. This may initially appear challenging, as native input elements cannot naturally display tags in the manner we desire. However, it’s actually quite straightforward. We can accomplish this by using an unordered list to iterate through the tags. Subsequently, we can apply custom styling and a bit of JavaScript to position them above the input field, achieving the desired visual effect. (Additionally, I’d like to note that I’m not an accessibility expert, so if you have expertise in this area, please feel free to provide feedback on potential improvements! 🙂)

<template>
<div class="tag-input">
<ul class="tags">
<li v-for="tag in tags" :key="tag" class="tag">
{{ tag }}
</li>
</ul>
</div>
</template>

We can enhance its appearance by adding some custom styles — give it a bit of flair.

<style scoped>
ul {
list-style: none;
display: flex;
align-items: center;
gap: 7px;
margin: 0;
padding: 0;
}
.tag {
background: rgb(250, 104, 104);
padding: 5px;
border-radius: 4px;
color: white;
white-space: nowrap;
transition: 0.1s ease background;
}
</style>

Now, it looks much prettier, but wait! We are not done yet. Let’s create the input element now.

Adding new tags

Now, let’s create a new variable called `newTag` to track the tag that the user is currently in the process of typing. To clarify, consider this scenario: we already have the “hello” and “world” tags saved in the `tags` variable. Now, the user begins typing “foo” but hasn’t yet committed this tag by pressing enter. In this case, “foo” will be stored in our new `newTag` variable. We will initialize it to an empty string ‘’. Thus, our script code looks as follows

<script setup lang="ts">
import {ref} from 'vue';
const tags = ref(['hello', 'world']);
const newTag = ref(''); // to keep up with new tag
</script>

Now we can add an input to the template just above the ul and bind the newTag variable to it with v-model.

<input v-model="newTag" type="text" />
<ul class="tags">...</ul>

With the binding established using v-model, as the user inputs text into the input field, the value of newTag will dynamically update to mirror what's being typed. To refine the input’s appearance, let’s apply a couple of styles.

input {
width: 100%;
padding: 10px;
}

We’re now just a step away from adding a new tag! Thanks to having the newTag value readily accessible in the JavaScript portion of our code, we're all set to craft an addTag function. This function should be defined within the setup function. addTag will receive a tag as an argument (we'll soon pass the newTag to it). Subsequently, we'll insert this tag into the tags variable.

const addTag = (tag: string) => {
tags.value.push(tag); // add the new tag to the tags array
};

Please take note that when using .push, you must specifically target tags.value. This is necessary because it represents a reactive reference created by Vue's ref function. In the template, you don't need to worry about this detail, as the .value is managed automatically. However, when operating within the script tag, if you wish to access or modify the tags value, you must explicitly reference tags.value.

Lastly, to prepare for the addition of another tag, we’ll reset the newTag value, ensuring that the input field is cleared and ready for the input of another tag.

const addTag = (tag: string) => {
tags.value.push(tag);
newTag.value = ""; // reset newTag
};

This function will automatically be available in our template as if we have returned it from the setup function.

Now, we can establish a binding between the addTag method and the keydown event on the input element while passing newTag as an argument. Additionally, we'll utilize the enter key modifier to ensure that a tag is added only when the user presses the enter key, rather than with every keystroke.

<input
...
@keydown.enter="addTag(newTag)"
/>

Hooray ! We have a functional tag input component ready. Now, let’s work on improving the user experience. For an enhanced user experience, we’ll consider the use of the “tab” key to create a new tag. In this case, we’ll also include the prevent modifier to prevent the tab key from shifting focus away from the input field.

<input
v-model="newTag"
type="text"
@keydown.enter="addTag(newTag)"
@keydown.prevent.tab="addTag(newTag)"
/>

Removing a Tag

Our next logical step is to enable the removal of tags from the input. Let’s proceed by creating a removeTag function. Its logic is quite straightforward. This function will accept the index of the tag to be removed and will use .splice to eliminate one item from the tags array at that specific index.

const removeTag = (index: number) => {
tags.value.splice(index, 1);
};

To ensure the best user experience, we will implement two methods for deleting tags:

  1. Backspacing on an Empty Input: Users can delete the last tag in the list by pressing the backspace key when the input field is empty (i.e., there’s no new tag value).
  2. Deleting Specific Tags: Users can delete specific tags by clicking an ‘x’ button associated with each tag.

These two deletion methods will enhance the flexibility and user-friendliness of the tag input component.

Click X to Remove a Specific Tag

Let’s address the second scenario first, as it’s the simpler of the two. Initially, we can insert an ‘x’ button within each tag and bind the removeTag function to its click event. To pass the correct index to the function, we can modify the v-for loop accordingly. This approach allows users to delete specific tags by clicking the associated 'x' button.

<li v-for="(tag, index) in tags" :key="tag" class="tag">
{{ tag }}
<button class="delete" @click="removeTag(index)">x</button>
</li>

Finally, we can smooth out the UI with a few styles.

.delete {
color: white;
background: none;
outline: none;
border: none;
cursor: pointer;
}

Backspacing to Remove the Last Tag

To facilitate the removal of the last tag when the user presses the delete key, we can invoke the removeTag method within the input's keydown event handler, utilizing the “delete” key modifier. To determine the index of the tag to remove, we can specify the length of the tags array minus one, considering that arrays are zero-indexed. This approach ensures that pressing the delete key with an empty input field will remove the last tag in the list.

<input
...
@keydown.delete="removeTag(tags.length - 1)"
>

Regrettably, this isn’t sufficient because it currently results in the removal of the last tag whenever the user presses the backspace key, even if their intention was simply to delete a character from the new tag. To fix that we can check to see if there are any characters present in the new tag and then only remove the last tag if there is not.

<input
...
@keydown.delete="newTag.length || removeTag(tags.length - 1)"
>

Positioning the Tags

Up to this point, we’ve achieved the core functionality of the tag input component, allowing users to input new tags, commit them with the enter key, and remove them using both the ‘x’ button and backspace. Congratulations on reaching this milestone!

However, there is a noticeable feature still missing: the tags’ positioning. Currently, they appear below the input field, which doesn’t quite give the impression of a tag input. To enhance the user experience, the tags should appear right within the input field.

To achieve this effect, most of the work can be handled through CSS. By setting the container element as relative and positioning the ul element absolutely, we can position the tags over the input field. This positioning can be adjusted, such as pushing the ul element 10px from the left to align with the input's padding. We will also give the ul a max width of 75% so that there is always room to the right of the tags to type. Then any overflow tags can scroll horizontally.

.tag-input {
position: relative;
}
ul{
...
max-width: 75%;
overflow-x: auto;
position: absolute;
top: 0;
bottom: 0;
left: 10px;
}

Positioning the Cursor

Using CSS alone won’t suffice because we also need to adjust the cursor’s position within the input field to ensure that typing occurs in the correct place, avoiding any overlap with existing tags.

To achieve this cursor positioning, we’ll need to dynamically update the left padding of the input field whenever a new tag is added. We can begin by creating a reactive reference to store the value of the left padding. This reference should be initialized to 10, which matches the padding on all sides of the input field.


const paddingLeft: number = ref(10);

Then we need to bind the paddingLeft variable to the style attribute of the input.

<input
...
:style="{ 'padding-left': ${paddingLeft}px }"
/>

Now, let’s dive into the exciting part: how do we determine the appropriate left padding value and when should we set it? Interestingly, breaking it down into these questions leads to a relatively straightforward solution. We aim to set the padding to the width of the ul element (with a bit of additional space for aesthetics), and we should do this whenever the width of the ul element changes, such as when a tag is added or removed.

First, let’s focus on obtaining the width. In Vue, when you need to interact directly with the DOM (which is occasionally necessary), you should create a template ref. A template ref is similar to a reactive ref created with the ref function, but instead of referencing primitive JavaScript data types, it references a DOM node. To create a template ref in Vue, you simply add the ref attribute to the DOM element you want to access and provide a name to reference it by.

<ul class="tags" ref="tagsUl">

Within the setup script, you create a reactive ref with the same name as the one you defined in the template. By this ref from the setup, you gain immediate access to the corresponding DOM element in the script section as soon as the component is mounted. This allows you to interact with the DOM element as needed for dynamic behavior.

Now that we have access to the ul element containing the tags, we can proceed to create a function that reads its width and sets the paddingLeft variable accordingly.

const setLeftPadding = () => {
const extraCushion = 15
paddingLeft.value = tagsUl.value.clientWidth + extraCushion;
}

To ensure that our function is called at the appropriate time, we can set up a watcher for the tags variable, utilizing the deep option. The use of the deep option is essential because the array itself is not reassigned; instead, its members change. To further guarantee the accuracy of the ul element's width, we will employ nextTick to ensure that the DOM updates are complete before performing width calculations. This way, our function will react to changes in the tags variable effectively.

import { ref, watch, nextTick } from "vue";
...
watch(tags, ()=> nextTick(setLeftPadding), {deep: true});

Additionally, it’s a good idea to invoke the setLeftPadding function when the component is mounted. This ensures that any existing tags, such as "hello" and "world," are correctly accounted for in the initial rendering, even before any changes are made.

import { ref, watch, nextTick, onMounted } from "vue";
...
onMounted(setLeftPadding)

There’s one more aspect we should address when the tags change, as evident in the GIF above. When the tags overflow the ul element, we should automatically scroll it to the end to ensure that the most recently added tags are visible. This can be seamlessly incorporated into the logic when we set the left padding.

tagsUl.value.scrollTo(tagsUl.value.scrollWidth, 0);

To make the function name more contextually fitting, we can rename it to onTagsChange.

const onTagsChange = () => {
// set left padding
const extraCushion = 15;
paddingLeft.value = tagsUl.value.clientWidth + extraCushion;
// scroll tags ul to end
tagsUl.value.scrollTo(tagsUl.value.scrollWidth, 0);
};
watch(tags, () => nextTick(onTagsChange), { deep: true });
onMounted(onTagsChange);

Making it Reusable

To make our component feel more like a native input that works seamlessly with v-model, we can follow the guidance provided in the Vue 3 documentation. In Vue 3, v-model on a custom component is equivalent to passing a modelValue prop and emitting an update:modelValue event.

To achieve this, we should initialize the tags value with the modelValue prop and emit an update:modelValue event when the tags change. Let's implement this by first accepting the modelValue prop and setting tags to its value.

const props = withDefaults(defineProps<TagInputProps>(), {
modelValue: () => [],
});

We’ve successfully managed to set up the input value coming into the component using the modelValue prop. To send this value out again, we can emit an update:modelValue event whenever the tags change. Fortunately, we already have the onTagsChange function in place to handle this.

To access the emit function, we can destructure the context object, which is accessible through the second argument of the setup method. This enables us to emit events and update the modelValue prop when necessary, ensuring that our component behaves seamlessly with v-model.

const emit = defineEmits(["update:modelValue"])
...
const onTagsChange = () => {
//...
emit("update:modelValue", tags.value)
}

Fantastic! With the adjustments made to accept and emit modelValue, you've successfully transformed your tag input component into a seamless and intuitive custom input that works just like a native input when used with v-model. This enhances the usability and integration of your component within Vue applications. Great job!

// App.vue
<template>
<div>
<TagInput v-model="tags" />
<ul>
<li v-for="tag in tags" :key="tag">{{ tag }}</li>
</ul>
</div>
</template>

<script>
import TagInput from "./components/TagInput.vue";
export default {
name: "App",
components: {
TagInput: TagInput,
},
data() {
return {
tags: ["Hello", "App"],
};
},
};
</script>

Conclusion

That’s wonderful to hear that you’ve built a flexible foundation for your tag input component! Features like tag options, preventing duplicate tags, handling empty tags, and showing the number of tags are excellent additions to enhance its functionality.

For those interested in exploring these additional features and seeing how they were implemented, I encourage you to check out the code in the GitHub repo. If you’re up for the challenge, consider forking the repo to create and experiment with your own innovative features. And if you do create something exciting, don’t forget to share your creation in the comments here as well as on our facebook page — sharing knowledge and ideas can inspire others in their Vue.js projects!

To learn vue js please check out my courses Vue.js Complete Course + Guide and Vue 3 Essentials. And most importantly, don't forget to star the repo!

Happy Coding! 😁

In Plain English

Thank you for being a part of our community! Before you go:

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in JavaScript in Plain English

New JavaScript and Web Development content every day. Follow to join our 3.5M+ monthly readers.

Written by Mayank Chaudhari

Technical Writer | Developer | Researcher | Freelancer | Open Source Contributor

No responses yet

Write a response