Input With Tags
An interactive tag badge input with keyboard submission, backspace deletion, and duplicate prevention.
ReactTailwind CSSFramer Motion
Dependenciesframer-motion, lucide-react
npm install framer-motion lucide-reactPreview
ReactTypeScriptTailwind
Press Enter or comma to add tag3 / 8 tags
Source Code
input-with-tags.tsxtsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"use client";
import React, { useState, KeyboardEvent, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Tag as TagIcon } from "lucide-react";
export interface InputWithTagsProps {
initialTags?: string[];
placeholder?: string;
maxTags?: number;
onTagsChange?: (tags: string[]) => void;
}
export function InputWithTags({
initialTags = ["React", "TypeScript", "Tailwind"],
placeholder = "Type tag and press Enter...",
maxTags = 8,
onTagsChange,
}: InputWithTagsProps) {
const [tags, setTags] = useState<string[]>(initialTags);
const [inputValue, setInputValue] = useState("");
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const addTag = (text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
if (tags.some((t) => t.toLowerCase() === trimmed.toLowerCase())) {
setDuplicateWarning(trimmed);
setTimeout(() => setDuplicateWarning(null), 1500);
return;
}
if (tags.length >= maxTags) return;
const newTags = [...tags, trimmed];
setTags(newTags);
setInputValue("");
onTagsChange?.(newTags);
};
const removeTag = (indexToRemove: number) => {
const newTags = tags.filter((_, idx) => idx !== indexToRemove);
setTags(newTags);
onTagsChange?.(newTags);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags.length - 1);
}
};
return (
<div className="w-full max-w-md space-y-2">
<div
onClick={() => inputRef.current?.focus()}
className="min-h-[46px] p-1.5 flex flex-wrap items-center gap-1.5 rounded-xl
bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800
focus-within:border-zinc-500 dark:focus-within:border-zinc-500
focus-within:ring-2 focus-within:ring-zinc-400/20
transition-all duration-200 cursor-text shadow-sm"
>
<AnimatePresence>
{tags.map((tag, index) => (
<motion.span
key={tag}
initial={{ opacity: 0, scale: 0.8, y: -2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.7, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={`inline-flex items-center gap-1.5 pl-2.5 pr-1.5 py-1 rounded-md text-xs font-medium
border transition-colors ${
duplicateWarning?.toLowerCase() === tag.toLowerCase()
? "bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-300 dark:border-red-800"
: "bg-white dark:bg-zinc-800 text-zinc-800 dark:text-zinc-200 border-zinc-200 dark:border-zinc-700 shadow-2xs"
}`}
>
<span>{tag}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(index);
}}
className="text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 rounded p-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
aria-label={`Remove tag ${tag}`}
>
<X className="w-3 h-3" />
</button>
</motion.span>
))}
</AnimatePresence>
<input
ref={inputRef}
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 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 outline-none px-2 py-1"
/>
</div>
<div className="flex items-center justify-between text-[11px] text-zinc-400 px-1">
<span className="flex items-center gap-1">
<TagIcon className="w-3 h-3" />
<span>Press Enter or comma to add tag</span>
</span>
<span>
{tags.length} / {maxTags} tags
</span>
</div>
</div>
);
}Usage
usage.tsxtsx
1
2
3
4
5
<InputWithTags
initialTags={["Next.js", "TypeScript", "Tailwind"]}
maxTags={10}
onTagsChange={(tags) => console.log(tags)}
/>Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| initialTags | string[] | Optional | ["React", "TypeScript", "Tailwind"] | Initial array of tags shown on first render. |
| placeholder | string | Optional | "Type tag and press Enter..." | Placeholder text shown when no tags are selected. |
| maxTags | number | Optional | 8 | Maximum allowable tags in the input. |
| onTagsChange | (tags: string[]) => void | Optional | undefined | Callback invoked whenever tags are added or removed. |
Types
types.tstsx
1
2
3
4
5
6
export interface InputWithTagsProps {
initialTags?: string[];
placeholder?: string;
maxTags?: number;
onTagsChange?: (tags: string[]) => void;
}