Tutorials 4 min read

How to create a tag input with Tailwind CSS: JavaScript and Alpine.js

Build a tag input with Tailwind CSS where Enter adds a removable tag chip, two ways: vanilla JavaScript or Alpine.js with x-model and x-for.

Let’s create a tag input: type something, hit Enter, and a removable tag pops into the list. We’ll build it twice, first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.

What is a tag input?

A tag input lets users add short labels to a list: type a tag, press Enter, and it appears as a chip with a remove button. They show up everywhere from social media platforms to content systems, because tagging makes content easier to organize, filter, and search.

Use cases

  • Content tagging: label posts or products so they are easy to organize.
  • Filtering and search: let users narrow results using the tags they add.
  • Suggestions and autocomplete: speed up tag entry with smart hints.
  • Tag clouds: visualize which topics come up most often.

The markup

  • id="tags-component": the parent element of the tag input and tag list.
  • id="tags-input-wrapper": the wrapper element for the input field and existing tags.
  • id="new-tag-input": the input field for adding new tags.
  • id="tags-list": the list element for displaying existing tags.

Note: The classes are removed for clarity.

html
<div id="tags-component">
  <!-- Tag Input -->
  <div id="tags-input-wrapper">
    <!-- Input Field -->
    <input id="new-tag-input" />
    <!-- Existing Tags -->
    <div id="tags-list"></div>
  </div>
</div>

The script

  • The keydown listener checks for Enter, prevents the default behavior, trims the value, and pushes the tag if it isn’t empty, then clears the input and re-renders.
  • renderTags(): clears the list and rebuilds it, one div per tag with a span for the text and a remove button with an SVG close icon.
  • Each remove button splices its tag out of the tags array and calls renderTags() again to update the list.
js
document.addEventListener("DOMContentLoaded", function () {
  const newTagInput = document.getElementById("new-tag-input");
  const tagsList = document.getElementById("tags-list");
  let tags = [];

  newTagInput.addEventListener("keydown", function (event) {
    if (event.key === "Enter") {
      event.preventDefault();
      const tag = newTagInput.value.trim();
      if (tag) {
        tags.push(tag);
        newTagInput.value = "";
        renderTags();
      }
    }
  });

  function renderTags() {
    tagsList.innerHTML = "";
    tags.forEach((tag, index) => {
      const tagElement = document.createElement("div");
      tagElement.className =
        "inline-flex items-center gap-x-0.5 rounded-md bg-orange-50 px-2 py-1 text-xs font-medium text-orange-700 ring-1 ring-inset ring-orange-700/10";

      const tagText = document.createElement("span");
      tagText.textContent = tag;

      const removeButton = document.createElement("button");
      removeButton.className = "ml-2";
      removeButton.innerHTML =
        '<svg class="w-4 h-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
      removeButton.addEventListener("click", function () {
        tags.splice(index, 1);
        renderTags();
      });

      tagElement.appendChild(tagText);
      tagElement.appendChild(removeButton);
      tagsList.appendChild(tagElement);
    });
  }
});

The Alpine.js version

Same component, no separate script: the state lives in x-data.

  • x-data: defines the data of the component, the tags array and the newTag input value.
  • x-model="newTag" with @keydown.enter.prevent: pushes the trimmed tag into the array and clears the input.
  • x-for="(tag, index) in tags" with :key="index": loops through the tags, with the key uniquely identifying each one.
  • x-text="tag": displays the tag’s text.
  • @click="tags.splice(index, 1)": removes the tag when the close button is clicked.

Note: The classes are removed for clarity.

html
<div
  x-data="{
        tags: [],
        addTag(tag) {
            if (tag.trim() !== '') {
                this.tags.push(tag.trim());
            }
        },
        removeTag(index) {
            this.tags.splice(index, 1);
        }
    }"
>
  <!-- Tag Input -->
  <div class="w-full" x-data="{ newTag: '', tags: [] }">
    <!-- Input Field -->
    <input
      x-model="newTag"
      @keydown.enter.prevent="
            if (newTag.trim() !== '') {
                tags.push(newTag.trim());
                newTag = '';
            }
        "
      type="text"
      placeholder="..."
    />
    <!-- Tags will be added here -->
    <div>
      <template x-for="(tag, index) in tags" :key="index">
        <div>
          <span x-text="tag"></span>
          <button @click="tags.splice(index, 1)" class="ml-2">
            <!--- Close Icon goes here -->
          </button>
        </div>
      </template>
    </div>
  </div>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

If Alpine is available, x-model and x-for handle the input and the rendering declaratively. The vanilla version builds the chips with createElement, which is more code but has zero dependencies.

Conclusion

That’s a tag input: an array, an Enter handler, and a remove button per chip. Remember to customize it and adapt it to your specific use case, and make it user-friendly and accessible.

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza