Skip to content
  1. Inputs
  2. Tag input

Tag input

A field that collects several short values. Type one, Enter or the button adds it as a chip, every chip removes itself. For a list the user picks from, use the Multi-select instead.

Restrict this key to specific IP addresses. Leave empty to allow all IPs.

  • 192.168.1.1
  • 10.0.0.0/24
import { Description, Field, Label } from '@/components/fieldset'
import { TagInput } from '@/components/tag-input'
import { useState } from 'react'

export default function Example() {
  let [ips, setIps] = useState<string[]>([])

  return (
    <Field>
      <Label>
        IP whitelist <span className="text-zinc-500 dark:text-zinc-400">(optional)</span>
      </Label>
      <Description>Restrict this key to specific IP addresses. Leave empty to allow all IPs.</Description>
      <TagInput
        className="mt-3"
        value={ips}
        onChange={setIps}
        name="ip_whitelist"
        placeholder="192.168.1.1 or 10.0.0.0/24"
        mono
      />
    </Field>
  )
}
<script setup lang="ts">
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import TagInput from '@/components/tag-input.vue'
import { ref } from 'vue'

const ips = ref<string[]>([])
</script>

<template>
  <Field>
    <Label>
      IP whitelist <span class="text-zinc-500 dark:text-zinc-400">(optional)</span>
    </Label>
    <Description>Restrict this key to specific IP addresses. Leave empty to allow all IPs.</Description>
    <TagInput v-model="ips" class="mt-3" name="ip_whitelist" placeholder="192.168.1.1 or 10.0.0.0/24" mono />
  </Field>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { XMarkIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useRef, useState } from 'react'
import { Button } from './button'
import { Input } from './input'

export function TagInput({
  value,
  onChange,
  placeholder,
  addLabel = 'Add',
  mono = false,
  name,
  className,
  ...props
}: {
  value: string[]
  onChange: (value: string[]) => void
  placeholder?: string
  addLabel?: string
  mono?: boolean
  name?: string
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>) {
  let [draft, setDraft] = useState('')
  let input = useRef<HTMLInputElement>(null)

  function add() {
    let tag = draft.trim()
    if (!tag || value.includes(tag)) return
    onChange([...value, tag])
    setDraft('')
  }

  return (
    <div {...props} className={className}>
      <div className="flex items-center gap-3">
        <Input
          ref={input}
          value={draft}
          placeholder={placeholder}
          className="min-w-0 flex-1"
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
              event.preventDefault()
              add()
            }
          }}
        />
        <Button outline disabled={!draft.trim()} onClick={add}>
          {addLabel}
        </Button>
      </div>
      {name && value.map((tag) => <input key={tag} type="hidden" name={name} value={tag} />)}
      {value.length > 0 && (
        <ul className="mt-3 flex flex-wrap gap-2">
          {value.map((tag) => (
            <li
              key={tag}
              className={clsx(
                'flex items-center gap-1.5 rounded-md bg-zinc-950/5 py-1 pr-1.5 pl-2.5 text-base/6 text-zinc-700 sm:text-sm/6 dark:bg-white/5 dark:text-zinc-300',
                mono && 'font-mono'
              )}
            >
              {tag}
              <button
                type="button"
                className="relative cursor-pointer rounded-sm p-0.5 text-zinc-400 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:hover:text-white"
                onClick={() => {
                  onChange(value.filter((item) => item !== tag))
                  input.current?.focus()
                }}
              >
                <span
                  className="absolute top-1/2 left-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
                  aria-hidden="true"
                />
                <XMarkIcon className="size-4 shrink-0" />
                <span className="sr-only">Remove {tag}</span>
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Also copy Button, Input.

<script setup lang="ts">
import { XMarkIcon } from '@heroicons/vue/16/solid'
import { ref } from 'vue'
import Button from './button.vue'
import Input from './input.vue'

withDefaults(
  defineProps<{
    placeholder?: string
    addLabel?: string
    mono?: boolean
    name?: string
  }>(),
  { placeholder: undefined, addLabel: 'Add', mono: false, name: undefined }
)

const tags = defineModel<string[]>({ default: () => [] })

const draft = ref('')
const row = ref<HTMLElement>()

function add() {
  const tag = draft.value.trim()
  if (!tag || tags.value.includes(tag)) return
  tags.value = [...tags.value, tag]
  draft.value = ''
}

function remove(tag: string) {
  tags.value = tags.value.filter((item) => item !== tag)
  row.value?.querySelector('input')?.focus()
}

function onKeyDown(event: KeyboardEvent) {
  if (event.key !== 'Enter' || event.isComposing) return
  event.preventDefault()
  add()
}
</script>

<template>
  <div>
    <div ref="row" class="flex items-center gap-3">
      <Input v-model="draft" :placeholder="placeholder" class="min-w-0 flex-1" @keydown="onKeyDown" />
      <Button outline :disabled="!draft.trim()" @click="add">{{ addLabel }}</Button>
    </div>
    <template v-if="name">
      <input v-for="tag in tags" :key="tag" type="hidden" :name="name" :value="tag" />
    </template>
    <ul v-if="tags.length > 0" class="mt-3 flex flex-wrap gap-2">
      <li
        v-for="tag in tags"
        :key="tag"
        class="flex items-center gap-1.5 rounded-md bg-zinc-950/5 py-1 pr-1.5 pl-2.5 text-base/6 text-zinc-700 sm:text-sm/6 dark:bg-white/5 dark:text-zinc-300"
        :class="mono && 'font-mono'"
      >
        {{ tag }}
        <button
          type="button"
          class="relative cursor-pointer rounded-sm p-0.5 text-zinc-400 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:hover:text-white"
          @click="remove(tag)"
        >
          <span class="absolute top-1/2 left-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" aria-hidden="true" />
          <XMarkIcon class="size-4 shrink-0" />
          <span class="sr-only">Remove {{ tag }}</span>
        </button>
      </li>
    </ul>
  </div>
</template>

Also copy Button, Input.

Component API

PropDefaultDescription
TagInputrenders a <div> element
value-The tags, in order.
onChange-Called with the new array after an add or a remove.
placeholder-Placeholder of the field.
addLabel'Add'Label of the button beside the field.
monofalseMonospace chips: IP addresses, hosts, keys.
name-Adds one hidden input per tag under this name, for a plain form post.
PropDefaultDescription
TagInputrenders a <div> element
v-model[]The tags, in order.
placeholder-Placeholder of the field.
addLabel'Add'Label of the button beside the field.
monofalseMonospace chips: IP addresses, hosts, keys.
name-Adds one hidden input per tag under this name, for a plain form post.

Examples

Basic example

Wrap it in a Field with its Label and Description, like any input. The button stays disabled until there is something to add, and a value already in the list is ignored:

Restrict this key to specific IP addresses. Leave empty to allow all IPs.

  • 192.168.1.1
  • 10.0.0.0/24
import { Description, Field, Label } from '@/components/fieldset'
import { TagInput } from '@/components/tag-input'
import { useState } from 'react'

export default function Example() {
  let [ips, setIps] = useState<string[]>([])

  return (
    <Field>
      <Label>
        IP whitelist <span className="text-zinc-500 dark:text-zinc-400">(optional)</span>
      </Label>
      <Description>Restrict this key to specific IP addresses. Leave empty to allow all IPs.</Description>
      <TagInput
        className="mt-3"
        value={ips}
        onChange={setIps}
        name="ip_whitelist"
        placeholder="192.168.1.1 or 10.0.0.0/24"
        mono
      />
    </Field>
  )
}
<script setup lang="ts">
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import TagInput from '@/components/tag-input.vue'
import { ref } from 'vue'

const ips = ref<string[]>([])
</script>

<template>
  <Field>
    <Label>
      IP whitelist <span class="text-zinc-500 dark:text-zinc-400">(optional)</span>
    </Label>
    <Description>Restrict this key to specific IP addresses. Leave empty to allow all IPs.</Description>
    <TagInput v-model="ips" class="mt-3" name="ip_whitelist" placeholder="192.168.1.1 or 10.0.0.0/24" mono />
  </Field>
</template>

Plain chips

Without mono, the chips take the text face. Name the button after what it adds:

  • vip
  • wholesale
<TagInput value={tags} onChange={setTags} name="tags" placeholder="Add a tag" addLabel="Add tag" />
<TagInput v-model="tags" name="tags" placeholder="Add a tag" add-label="Add tag" />