Installation
Add this component to your project using the CLI:
terminal
npx vui-registry-cli-v1 add animated-tags-inputSource Code
animated-tags-input.tsx
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Plus } from 'lucide-react';
interface AnimatedTagsInputProps {
tags?: string[];
setTags?: (tags: string[]) => void;
maxTags?: number;
placeholder?: string;
}
export default function AnimatedTagsInput({
tags: controlledTags,
setTags: setControlledTags,
maxTags = 10,
placeholder = "Add a tag..."
}: AnimatedTagsInputProps) {
const [internalTags, setInternalTags] = useState(['React', 'Next.js', 'Tailwind']);
const [inputValue, setInputValue] = useState('');
const isControlled = controlledTags !== undefined && setControlledTags !== undefined;
const tags = isControlled ? controlledTags : internalTags;
const setTags = isControlled ? setControlledTags : setInternalTags;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
const newTag = inputValue.trim();
if (!tags.includes(newTag) && tags.length < maxTags) {
setTags([...tags, newTag]);
setInputValue('');
}
}
};
const removeTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove));
};
return (
<div className="w-full max-w-md p-4 bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800 rounded-xl shadow-sm font-sans">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Add Skills
</label>
<span className="text-xs text-neutral-400">
{tags.length}/{maxTags}
</span>
</div>
<div className="flex flex-wrap gap-2 p-2 min-h-[100px] content-start bg-neutral-50 dark:bg-neutral-900 rounded-lg border border-transparent focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/20 transition-all">
<AnimatePresence>
{tags.map((tag) => (
<motion.span
key={tag}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
layout
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium group"
>
{tag}
<button
onClick={() => removeTag(tag)}
className="p-0.5 rounded-full hover:bg-blue-200 dark:hover:bg-blue-500/30 transition-colors"
>
<X size={12} />
</button>
</motion.span>
))}
</AnimatePresence>
{tags.length < maxTags && (
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] bg-transparent outline-none text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 text-sm py-1"
/>
)}
</div>
<p className="mt-2 text-xs text-neutral-500">
Press <kbd className="px-1 py-0.5 rounded bg-neutral-200 dark:bg-neutral-800 font-mono text-[10px]">Enter</kbd> to add a tag.
</p>
</div>
);
}
Dependencies
framer-motion: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| tags | string[] | [] | Initial array of tags. |
| setTags | (tags: string[]) => void | undefined | Callback to update tags. |
| maxTags | number | 10 | Maximum number of tags allowed. |
| placeholder | string | "Add a tag..." | Input placeholder text. |
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.

