Velocity UI
Loading…
Menu

Tag Input

An animated tag input component with limit enforcement and keyboard support.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add tag-input

Source Code

tag-input.tsx
'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: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
placeholderstringAdd tags...Input placeholder text.
initialTagsstring[]['React','Next.js','Tailwind']Initial tags to populate the field.
maxTagsnumber10Maximum number of tags allowed.
onTagsChange(tags: string[]) => voidundefinedCallback fired when tags update.
classNamestringundefinedAdditional CSS classes.
Context Worth Keeping In Orbit

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.