Ever wonder how to build a fancy tag input component like those you see in your blog admin panel or concept document? Well, think no more! In this article, we will use Vue 3’s composite API to make a reusable tag input component. In this article, we’ll use Vue 3’s composite API to make our own reusable tag input component. Along the way, we’ll introduce some important concepts you should know in order to use Vue 3’s composite API effectively.

As a fair warning, if you’re just looking for an out-of-the-box solution, grab an existing UI library or a separate TAB input that’s regularly maintained by a reputable development/design team. The label input components in this article are not necessarily production-ready. But if you’re here to learn more about composing apis and building custom reusable components, read on

What we’re building

Here’s an example we’re going to set up. Tag input allows you to enter custom tags and then submit them to input when the Enter key is pressed. You can also remove labels by backing up on an empty input port (that is, an input port that currently has no input label) or by clicking the small X at the end of each label.

The input method also supports limiting labels to only certain values that are displayed in focus and filtered out as the user enters.

Most importantly, these capabilities will be built up so that the component can be reused and has an intuitive interface that works just like normal input elements.

View it in the code sandbox

Follow up and display labels

Now that we’ve seen what we’re building, let’s dive into the code. In the code sandbox, I used the Vue CLI to bootstrap a Vue 3 project. You can also do this, or use Vue CLI or Vite on your local machine. Then, let’s throw some template code into a single file component.

// components/TagInput.vue
<template>

</template>
<script>
export default {
 setup(){

 }
}
</script>
Copy the code

Next, let’s import the ref function from Vue and use it to store our component’s existing labels. We can initialize it as an array with some fake labels so we can see better what we’re using. Finally, we need to return it from the setup function to expose it in the template.

<script>
import {ref} from 'vue'
export default {
 setup(){
    const tags = ref(['hello', 'world']);
    return { tags }
 }
}
</script>
Copy the code

With the method of tracking tags, we can now display them in the template. This is probably the most puzzling part, because there is no native input method that displays the label as we would like, but it’s actually quite simple. We can use an unordered list that loops through the labels. Then, later we can use some custom styles and a little javascript to get things positioned on the input field the way you want them to be positioned. (Also, I’m not an accessibility expert, so if you are, please leave a comment if this can be improved! 🙂)

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

This results in an ordinary-looking unordered list.

Then, we can dress it up with some custom styles.

<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; The transition: 0.1 s ease background; } </style>Copy the code

These are definitely starting to look like tags, but where is the input? It’s coming, but we need to do some groundwork first.

The new label

Now, let’s create a variable called newTag to keep up with the current tag the user is entering. What do I mean? Think of it this way: a tag input already has “Hello” and “world “tags. These tags are stored in tags. The user then starts typing “foo” but hasn’t yet hit enter to submit the label. Foo “is what will be stored in our new newTag variable.

<script>
import {ref} from 'vue'
export default {
 setup(){
    const tags = ref([]);
    const newTag = ref('') //keep up with new tag
    return { tags, newTag }
 }
}
</script>
Copy the code

Now we can add an input to the template, just above ul, and bind the newTag variable to it via the V-Model.

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

When binding is in place, the value of newTag will be updated to reflect what is in the input box when the user enters it.

We can also add styles to make the input more concise.

input {
  width: 100%;
  padding: 10px;
}
Copy the code

Add a new label

So this brings us very close to adding a newTag and since we have a ready-made newTag value in the Javascript world, we’re going to create an addTag function. This should be defined in the setup function. AddTag will take a tag to add as an argument (we’ll pass newTag to it in a minute). We will then push the tag into the tags variable.

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

Note here that when you call.push, you have to target tags. Value. This is because it is a reactive reference created by Vue’s ref function. When we’re in the template, we don’t have to worry about this because.value is already handled automatically for us. However, in script tags, if we want to get or set the value of tags, we need to use tags. Value.

Finally, we reset the value of newTag so that as soon as a newTag is added, the input is cleared and ready for another tag.

const addTag = (tag) => {
  tags.value.push(tag);
  newTag.value = ""; // reset newTag
};
Copy the code

With the addTag function, we can now expose it to the template by returning it from the setup function.

setup(){
  //...
  return { tags, newTag, addTag }
}
Copy the code

Note that from now on, for the sake of brevity, I will not mention any variables or functions that need to be returned from the setup method. Take this as your warning, and do it yourself for any variables you need in future templates.

Now we can bind the addTag method to the input keystroke event and attach the newTag. We will also use the Enter key modifier so that it adds a label only when the user presses the Enter key, not every time the key is pressed.

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

To improve the user experience, we will also consider pressing the TAB key to enter a new TAB. This time we also need to add the Prevent modifier so that the tag doesn’t take the focus away from the input.

<input
  ...
  @keydown.enter="addTag(newTag)"
  @keydown.prevent.tab="addTag(newTag)"
/>
Copy the code

Delete a label

Now our logical next step is to allow tags to be removed from the input, so let’s create a removeTag function. The logic is fairly simple. It receives the index of the tag to be removed and, using.splice, removes an item from the index’s tag array.

const removeTag = (index) => {
  tags.value.splice(index, 1);
};
Copy the code

In order to meet the best user experience, we will provide two ways to delete tags.

  1. Allows backing down on an empty input (that is, one without a new label value) to remove the last label in the list
  2. Allows by clicking on the TABxTo delete a specific label.

Click X to remove a particular label

Let’s deal with the second case first, because it’s actually the easier of the two. First, we can bind the removeTag function to its click event by adding an X button to each tag. You can then access the index passing the function by modifying v-for.

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

Finally, we can use some styles to smooth out the user interface.

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

Removes the last label move back

To remove the last tag, we should be able to call the removeTag method of the input keystroke event via the DELETE modifier. For the index to be dropped, we can specify the label length minus 1, because the array length is the index of 0.

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

Unfortunately, that’s not enough, because now as soon as the user presses the backspace key, the last TAB is removed, even if they just want to backspace one character from the new TAB.

To solve this problem, we can check to see if any characters exist in the new label, and if not, just delete the last label.

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

tag

So far, we’ve been able to enter a new label in the input box and press Enter to submit. We can then delete them again with the X button or back. Congratulations to you! At this point, we have completed the basic functions of label input.

There are some embellishments that could be added, but one obvious feature that is still lacking is the placement of tags. Now, they just appear below the input, which doesn’t feel like a tag input at all. They need to be present in the input.

In most cases, you can handle it with some CSS. To place the label above the input, we can make the container elements opposite each other and position the label UL absolutely, pushing it from the left to 10px to match the padding of the input.

.tag-input {
  position: relative;
}
ul{
  ...
  position: absolute;
  top: 0;
  bottom: 0;
  left: 10px;
}
Copy the code

We can also give ul a maximum width of 75% so that there is always room for input on the right side of the label. Any spilled tabs can then be scrolled horizontally.

ul {
  ...
  max-width: 75%;
  overflow-x: auto;
}
Copy the code

Position the cursor

Relying on CSS alone is not enough, because we still need to push the cursor to the appropriate position of the input. Otherwise, we’d be typing under any existing labels

To push the cursor into place, we need to dynamically update the left padding on the input as we add a new label. We can preserve the value of the left fill by setting up an equation reference. It’s going to be initialized to 10, because that’s the padding of the entire input.

setup(){
  //...
  const paddingLeft = ref(10);
}
Copy the code

We then need to bind the paddingLeft variable to the input style property.

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

Now for the fun part! How do we know when to set the left padding to what? In fact, to break it down into a question like this is actually to get a fairly simple answer. We want to set the padding to the width of the tags UL (maybe a little more, to give it some breathing room), and we want to do this when the width of the tags UL changes (that is, when a tag is added or removed).

Let’s deal with getting the width first. When you want to interact directly with the DOM in Vue, you need to create a template reference, which is necessary in rare cases. A template reference is like a reactive reference created with the REF function, but instead of referring to the original Javascript data type, it refers to a DOM node. To create a template reference in Vue, all you need to do is add the ref attribute to the DOM element you want to access and give it a name to reference it.

<ul class="tags" ref="tagsUl">
Copy the code

Then in the setup function, you create a reactive ref with the same name, return it from the setup method, and voila, once the component is installed, you can access the DOM elements of the script part.

setup(){
  //...
  const tagsUl = ref(null)
  return {
    //...
    tagsUl
  }
}
Copy the code

With the tag ul in hand, we can now create a function that reads its width and sets the paddingLeft variable appropriately.

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

Now, to call it at the right time, we can observe the tags with the depth option. The depth option is necessary because the array itself has not been reassigned, but rather its members have changed. We will also use nextTick to ensure that the DOM update is complete and the label UL widths are accurate

import { ref, watch, nextTick } from "vue"; export default{ setup(){ //... watch(tags, ()=> nextTick(setLeftPadding), {deep: true}); }}Copy the code

Let’s also call the setLeftPadding function when the component is installed. This will take into account any tags like “Hello” and “world “that already exist on the input before any changes are made.

import { ref, watch, nextTick, onMounted } from "vue";
export default{
  setup(){
    //...
    onMounted(setLeftPadding)
  }
}
Copy the code

There’s another thing we can do when the tag changes, as you can see in the GIF above. When the tag overflows the tag ul, we need to scroll the tag ul to the end so that you can see the recently added tag. We can do this by setting the padding on the left.

const setLeftPadding = () => {
  //...
  tagsUl.value.scrollTo(tagsUl.value.scrollWidth, 0);
};
Copy the code

Then, to make the function name more appropriate, we can change 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);
Copy the code

Make it reusable

So far, there is no real way to use this component. Its value is set inside and never comes out again. Let’s solve this problem.

How can we make it feel more like a local input working with the V-Model? The documentation for Vue 3 states that a custom component on “3.XV-Model is equivalent to passing a modelValue item and issuing an Update :modelValue event”

Therefore, this means that we need to initialize the tag’s value as a modelValue item and issue an Update :modelValue event when the tag changes.

Let’s do it. First, we need to accept the modelValue item and then set the tags to it.

<script>
export default{
  props:{
    modelValue: { type: Array, default: ()=> [] },
  },
  setup(props){
    const tags = ref(props.modelValue);
  }
}
</script>
Copy the code

This will anchor the values coming into the component. To send it out again, we can issue an Update :modelValue event when the tag changes. And it happens that we already have an onTagsChange function.

To access emit, we can deconstruct the context object, which is available as the second argument to the setup method.

setup(props, {emit}){
  //...
  const onTagsChange = () => {
    //...
    emit("update:modelValue", tags.value)
  }
}
Copy the code

Now we can use the V-Model to interact with the tag input to feel like a local input.

// 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>Copy the code

conclusion

There’s a lot more that could be done to enhance the functionality of our tag input, but adding these new features is almost trivial because of the straightforward code base we’ve maintained so far. Some of the features I’ve done in this CodeSandbox include.

  • Label options
  • Prevent duplicate labels
  • Preventing empty labels
  • And display the number of labels

If you’re interested, take a look at the code to see how these other features are implemented. If you’re up for the challenge, fork the sandbox and create your own super features (and if you do, don’t forget to share your creations in the comments here)!