Tag Input
Type and press Enter to add tags; Backspace removes the last.
Topics
Press Enter or comma to add · Backspace to remove
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>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/tag-input.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.