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.
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>
)
}<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>Component API
| Prop | Default | Description |
|---|---|---|
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. |
mono | false | Monospace chips: IP addresses, hosts, keys. |
name | - | Adds one hidden input per tag under this name, for a plain form post. |
| Prop | Default | Description |
|---|---|---|
TagInputrenders a <div> element | ||
v-model | [] | The tags, in order. |
placeholder | - | Placeholder of the field. |
addLabel | 'Add' | Label of the button beside the field. |
mono | false | Monospace 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:
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" />