A chip-based input where tags commit on Enter or comma, animate in with scale and blur, and neighbors close the gap when one is removed.
npx shadcn@latest add @paragon/tag-input"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
export interface TagInputProps
extends Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange" | "type"
> {
/** Controlled list of tags. */
value?: string[];
/** Initial tags when uncontrolled. */
defaultValue?: string[];
/** Called with the next list whenever tags are added or removed. */
onValueChange?: (tags: string[]) => void;
/** Hard cap on the number of tags. */
maxTags?: number;
}
/**
* A text input that turns typed values into removable chips.
* Enter or comma commits the draft; Backspace on an empty draft
* highlights the last chip, then deletes it on the next press.
*/
export function TagInput({
value: valueProp,
defaultValue,
onValueChange,
maxTags,
className,
id,
placeholder = "Add a tag…",
disabled,
onKeyDown,
onBlur,
onPaste,
...props
}: TagInputProps) {
const reduced = useReducedMotion();
const [uncontrolled, setUncontrolled] = React.useState<string[]>(
defaultValue ?? [],
);
const tags = valueProp ?? uncontrolled;
const [draft, setDraft] = React.useState("");
// Whether the last chip is highlighted for pending deletion.
const [marked, setMarked] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const setTags = (next: string[]) => {
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
};
const addFromDraft = (raw: string) => {
const parts = raw
.split(",")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) return;
const next = [...tags];
for (const part of parts) {
if (next.includes(part)) continue;
if (maxTags !== undefined && next.length >= maxTags) break;
next.push(part);
}
if (next.length !== tags.length) setTags(next);
setDraft("");
};
const removeTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag));
setMarked(false);
};
return (
<div
onClick={() => inputRef.current?.focus()}
className={cn(
"flex min-h-9 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-sm transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30",
disabled && "pointer-events-none opacity-50",
className,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{tags.map((tag, i) => {
const isMarked = marked && i === tags.length - 1;
return (
<motion.span
key={tag}
layout={!reduced}
initial={
reduced
? { opacity: 0 }
: { opacity: 0, scale: 0.9, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reduced
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
scale: 0.9,
filter: "blur(4px)",
transition: { duration: 0.15, ease: EASE_EXIT },
}
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className={cn(
"inline-flex items-center gap-0.5 rounded-md py-0.5 pr-0.5 pl-2 text-xs font-medium transition-colors duration-150",
isMarked
? "bg-destructive/10 text-destructive"
: "bg-secondary text-secondary-foreground",
)}
>
{tag}
<button
type="button"
aria-label={`Remove ${tag}`}
disabled={disabled}
onClick={() => removeTag(tag)}
className="relative flex size-4 items-center justify-center rounded-sm opacity-60 transition-opacity duration-100 after:absolute after:top-1/2 after:left-1/2 after:size-6 after:-translate-x-1/2 after:-translate-y-1/2 hover:opacity-100 focus-visible:opacity-100"
>
<X className="size-3" aria-hidden />
</button>
</motion.span>
);
})}
</AnimatePresence>
<input
ref={inputRef}
id={id}
type="text"
value={draft}
disabled={disabled}
placeholder={tags.length === 0 ? placeholder : ""}
onChange={(e) => {
setDraft(e.target.value);
setMarked(false);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented || e.nativeEvent.isComposing) return;
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addFromDraft(draft);
} else if (e.key === "Backspace" && draft === "" && tags.length > 0) {
e.preventDefault();
if (marked) removeTag(tags[tags.length - 1]);
else setMarked(true);
} else if (e.key === "Escape") {
setMarked(false);
}
}}
onPaste={(e) => {
onPaste?.(e);
if (e.defaultPrevented) return;
const text = e.clipboardData.getData("text");
if (text.includes(",")) {
e.preventDefault();
addFromDraft(draft + text);
}
}}
onBlur={(e) => {
setMarked(false);
onBlur?.(e);
}}
className="min-w-20 flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
{...props}
/>
</div>
);
}