Tag Input
An animated tag input component with limit enforcement and keyboard support.
Installation
Add this component to your project using the CLI:
npx vui-registry-cli-v1 add tag-inputSource Code
'use client'
import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Plus, Hash } from 'lucide-react'
import { cn } from '@/lib/utils'
interface TagInputProps {
placeholder?: string
initialTags?: string[]
maxTags?: number
onTagsChange?: (tags: string[]) => void
className?: string
}
export function TagInput({
placeholder = 'Add tags...',
initialTags = ['React', 'Next.js', 'Tailwind'],
maxTags = 10,
onTagsChange,
className,
}: TagInputProps) {
const [tags, setTags] = useState<string[]>(initialTags)
const [inputValue, setInputValue] = useState('')
const [isFocused, setIsFocused] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTag()
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
removeTag(tags.length - 1)
}
}
const addTag = () => {
const trimmedInput = inputValue.trim()
if (trimmedInput && !tags.includes(trimmedInput) && tags.length < maxTags) {
const newTags = [...tags, trimmedInput]
setTags(newTags)
setInputValue('')
onTagsChange?.(newTags)
}
}
const removeTag = (index: number) => {
const newTags = tags.filter((_, i) => i !== index)
setTags(newTags)
onTagsChange?.(newTags)
}
return (
<div className={cn('w-full max-w-md mx-auto font-sans', className)}>
<motion.div
className={cn(
'relative min-h-[56px] flex flex-wrap items-center gap-2 p-2 rounded-xl border transition-all duration-300 bg-background/50 backdrop-blur-sm',
isFocused
? 'border-primary ring-2 ring-primary/20 shadow-[0_0_20px_-5px_rgba(var(--primary),0.3)]'
: 'border-foreground/10 hover:border-foreground/20'
)}
onClick={() => inputRef.current?.focus()}
layout
>
<AnimatePresence mode='popLayout'>
{tags.map((tag, index) => (
<motion.div
key={tag}
layout
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
className="group flex items-center gap-1.5 pl-2.5 pr-1.5 py-1.5 rounded-lg bg-foreground/5 border border-foreground/5 text-sm font-medium text-foreground hover:bg-foreground/10 transition-colors cursor-default"
>
<Hash className="w-3.5 h-3.5 text-muted-foreground opacity-50" />
<span>{tag}</span>
<button
onClick={(e) => {
e.stopPropagation()
removeTag(index)
}}
className="p-0.5 rounded-md hover:bg-foreground/10 hover:text-red-500 text-muted-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={tags.length === 0 ? placeholder : ''}
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground/70 h-8"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
<span className="text-[10px] text-muted-foreground font-medium px-1.5 py-0.5 rounded border border-foreground/10 bg-foreground/5">
{tags.length}/{maxTags}
</span>
</div>
</motion.div>
<div className="mt-2 flex justify-between text-xs text-muted-foreground px-1">
<span>Press <kbd className="font-sans px-1 py-0.5 rounded bg-foreground/5 border border-foreground/10">Enter</kbd> to add</span>
{tags.length >= maxTags && (
<span className="text-amber-500">Max tags reached</span>
)}
</div>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| placeholder | string | Add tags... | Input placeholder text. |
| initialTags | string[] | ['React','Next.js','Tailwind'] | Initial tags to populate the field. |
| maxTags | number | 10 | Maximum number of tags allowed. |
| onTagsChange | (tags: string[]) => void | undefined | Callback fired when tags update. |
| className | string | undefined | Additional CSS classes. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.

