Tag Input

Type and press Enter to add tags; Backspace removes the last.

Topics

Press Enter or comma to add · Backspace to remove

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/tag-input.tsx

    components/ui/tag-input.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { X } from "lucide-react";
    import { useId, useState, type KeyboardEvent } from "react";
    
    type TagInputProps = {
      tags: string[];
      onChange: (tags: string[]) => void;
      placeholder?: string;
      max?: number;
    };
    
    export function TagInput({ tags, onChange, placeholder = "Add a tag…", max = 10 }: TagInputProps) {
      const [text, setText] = useState("");
      const id = useId();
    
      function commit() {
        const t = text.trim().replace(/,$/, "");
        if (t && !tags.includes(t) && tags.length < max) onChange([...tags, t]);
        setText("");
      }
    
      function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
        if (e.key === "Enter" || e.key === ",") {
          e.preventDefault();
          commit();
        } else if (e.key === "Backspace" && !text && tags.length) {
          onChange(tags.slice(0, -1));
        }
      }
    
      return (
        <label htmlFor={id} className="flex min-h-12 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border border-zinc-200 bg-white p-2 transition focus-within:border-blue-600 focus-within:ring-4 focus-within:ring-blue-600/10">
          <AnimatePresence initial={false}>
            {tags.map((t) => (
              <motion.span
                key={t}
                layout
                initial={{ opacity: 0, scale: 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.8 }}
                className="inline-flex items-center gap-1 rounded-lg bg-zinc-100 py-1 pl-2.5 pr-1 text-sm text-zinc-800"
              >
                {t}
                <button type="button" onClick={() => onChange(tags.filter((x) => x !== t))} aria-label={`Remove ${t}`} className="grid size-5 place-items-center rounded-md text-zinc-500 hover:bg-zinc-200 hover:text-zinc-900">
                  <X className="size-3" />
                </button>
              </motion.span>
            ))}
          </AnimatePresence>
          <input
            id={id}
            value={text}
            onChange={(e) => setText(e.target.value)}
            onKeyDown={onKeyDown}
            onBlur={commit}
            placeholder={tags.length ? "" : placeholder}
            aria-label="Add tag"
            className="min-w-24 flex-1 bg-transparent px-1.5 py-1 text-sm outline-none placeholder:text-zinc-400"
          />
        </label>
      );
    }
    
    export default function Demo() {
      const [tags, setTags] = useState(["react", "design", "motion"]);
      return (
        <div className="w-full max-w-md">
          <p className="mb-2 text-sm font-medium text-zinc-900">Topics</p>
          <TagInput tags={tags} onChange={setTags} />
          <p className="mt-2 text-xs text-zinc-500">Press Enter or comma to add · Backspace to remove</p>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.