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-react

Preview

ReactTypeScriptTailwind
Press Enter or comma to add tag3 / 8 tags

Source Code

input-with-tags.tsx
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.tsx
1
2
3
4
5
<InputWithTags
  initialTags={["Next.js", "TypeScript", "Tailwind"]}
  maxTags={10}
  onTagsChange={(tags) => console.log(tags)}
/>

Props

PropTypeRequiredDefaultDescription
initialTagsstring[]Optional["React", "TypeScript", "Tailwind"]Initial array of tags shown on first render.
placeholderstringOptional"Type tag and press Enter..."Placeholder text shown when no tags are selected.
maxTagsnumberOptional8Maximum allowable tags in the input.
onTagsChange(tags: string[]) => voidOptionalundefinedCallback invoked whenever tags are added or removed.

Types

types.ts
1
2
3
4
5
6
export interface InputWithTagsProps {
  initialTags?: string[];
  placeholder?: string;
  maxTags?: number;
  onTagsChange?: (tags: string[]) => void;
}