Tags Input
Original · freetag input, add and remove chips
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/inp-tags.json"use client";
import {
useId,
useRef,
useState,
type KeyboardEvent,
type ClipboardEvent,
type MouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Accent = "indigo" | "violet" | "emerald" | "sky" | "amber";
type Tag = {
id: string;
value: string;
invalid: boolean;
};
type TagFieldProps = {
label: string;
hint: string;
placeholder: string;
accent: Accent;
initial: string[];
suggestions?: string[];
maxTags?: number;
validate?: (value: string) => boolean;
};
const ringMap: Record<Accent, string> = {
indigo: "focus-within:border-indigo-400 focus-within:ring-indigo-500/25",
violet: "focus-within:border-violet-400 focus-within:ring-violet-500/25",
emerald: "focus-within:border-emerald-400 focus-within:ring-emerald-500/25",
sky: "focus-within:border-sky-400 focus-within:ring-sky-500/25",
amber: "focus-within:border-amber-400 focus-within:ring-amber-500/25",
};
const chipMap: Record<Accent, string> = {
indigo:
"bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/25",
violet:
"bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-200 dark:ring-violet-400/25",
emerald:
"bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/25",
sky: "bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-200 dark:ring-sky-400/25",
amber:
"bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-400/25",
};
const removeHoverMap: Record<Accent, string> = {
indigo: "hover:bg-indigo-200/70 dark:hover:bg-indigo-400/25",
violet: "hover:bg-violet-200/70 dark:hover:bg-violet-400/25",
emerald: "hover:bg-emerald-200/70 dark:hover:bg-emerald-400/25",
sky: "hover:bg-sky-200/70 dark:hover:bg-sky-400/25",
amber: "hover:bg-amber-200/70 dark:hover:bg-amber-400/25",
};
const suggestMap: Record<Accent, string> = {
indigo:
"border-indigo-200 text-indigo-700 hover:bg-indigo-50 focus-visible:ring-indigo-500/40 dark:border-indigo-400/25 dark:text-indigo-200 dark:hover:bg-indigo-500/10",
violet:
"border-violet-200 text-violet-700 hover:bg-violet-50 focus-visible:ring-violet-500/40 dark:border-violet-400/25 dark:text-violet-200 dark:hover:bg-violet-500/10",
emerald:
"border-emerald-200 text-emerald-700 hover:bg-emerald-50 focus-visible:ring-emerald-500/40 dark:border-emerald-400/25 dark:text-emerald-200 dark:hover:bg-emerald-500/10",
sky: "border-sky-200 text-sky-700 hover:bg-sky-50 focus-visible:ring-sky-500/40 dark:border-sky-400/25 dark:text-sky-200 dark:hover:bg-sky-500/10",
amber:
"border-amber-200 text-amber-800 hover:bg-amber-50 focus-visible:ring-amber-500/40 dark:border-amber-400/25 dark:text-amber-100 dark:hover:bg-amber-500/10",
};
const invalidChip =
"bg-rose-50 text-rose-700 ring-1 ring-rose-300 dark:bg-rose-500/15 dark:text-rose-200 dark:ring-rose-400/35";
let tagSeq = 0;
function nextId(): string {
tagSeq += 1;
return `tag-${tagSeq}`;
}
function TagField({
label,
hint,
placeholder,
accent,
initial,
suggestions = [],
maxTags,
validate,
}: TagFieldProps) {
const reduced = useReducedMotion();
const inputId = useId();
const hintId = useId();
const statusId = useId();
const inputRef = useRef<HTMLInputElement | null>(null);
const shakeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [tags, setTags] = useState<Tag[]>(() =>
initial.map((value) => ({
id: nextId(),
value,
invalid: validate ? !validate(value) : false,
})),
);
const [draft, setDraft] = useState("");
const [shaking, setShaking] = useState(false);
const [status, setStatus] = useState("");
const atLimit = typeof maxTags === "number" && tags.length >= maxTags;
function announce(message: string) {
setStatus(message);
}
function reject(message: string) {
announce(message);
if (reduced) return;
setShaking(true);
if (shakeTimer.current) clearTimeout(shakeTimer.current);
shakeTimer.current = setTimeout(() => setShaking(false), 320);
}
function commit(raw: string) {
const pieces = raw
.split(/[,\n]/)
.map((piece) => piece.trim())
.filter(Boolean);
if (pieces.length === 0) return;
setTags((current) => {
const next = [...current];
const seen = new Set(next.map((tag) => tag.value.toLowerCase()));
let added = 0;
let blocked = false;
for (const piece of pieces) {
if (seen.has(piece.toLowerCase())) {
blocked = true;
continue;
}
if (typeof maxTags === "number" && next.length >= maxTags) {
blocked = true;
break;
}
next.push({
id: nextId(),
value: piece,
invalid: validate ? !validate(piece) : false,
});
seen.add(piece.toLowerCase());
added += 1;
}
if (added > 0) {
announce(
`Added ${pieces[pieces.length - 1]}. ${next.length} ${label.toLowerCase()} total.`,
);
} else if (blocked) {
reject(
typeof maxTags === "number" && current.length >= maxTags
? `Limit of ${maxTags} reached.`
: "That tag is already added.",
);
}
return next;
});
setDraft("");
}
function removeAt(id: string, value: string) {
setTags((current) => current.filter((tag) => tag.id !== id));
announce(`Removed ${value}.`);
inputRef.current?.focus();
}
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commit(draft);
return;
}
if (event.key === "Backspace" && draft.length === 0 && tags.length > 0) {
event.preventDefault();
const last = tags[tags.length - 1];
removeAt(last.id, last.value);
}
}
function onPaste(event: ClipboardEvent<HTMLInputElement>) {
const text = event.clipboardData.getData("text");
if (/[,\n]/.test(text)) {
event.preventDefault();
commit(text);
}
}
function onContainerMouseDown(event: MouseEvent<HTMLDivElement>) {
if (event.target === inputRef.current) return;
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
inputRef.current?.focus();
}
const openSuggestions = suggestions.filter(
(item) => !tags.some((tag) => tag.value.toLowerCase() === item.toLowerCase()),
);
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-baseline justify-between gap-3">
<label
htmlFor={inputId}
className="text-sm font-medium text-slate-800 dark:text-slate-100"
>
{label}
</label>
{typeof maxTags === "number" ? (
<span
className={`text-xs tabular-nums ${
atLimit
? "font-semibold text-rose-500 dark:text-rose-300"
: "text-slate-400 dark:text-slate-500"
}`}
>
{tags.length} / {maxTags}
</span>
) : null}
</div>
<div
onMouseDown={onContainerMouseDown}
className={`inptags-shake-target flex min-h-[3rem] flex-wrap items-center gap-1.5 rounded-xl border border-slate-300 bg-white/80 p-2 shadow-sm backdrop-blur transition-shadow ring-0 focus-within:ring-4 dark:border-slate-700 dark:bg-slate-900/60 ${ringMap[accent]} ${
shaking ? "inptags-shake-anim" : ""
}`}
>
<ul role="list" aria-label={label} className="contents">
<AnimatePresence initial={false}>
{tags.map((tag) => (
<motion.li
key={tag.id}
layout={!reduced}
initial={reduced ? false : { opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
className={`group inline-flex items-center gap-1 rounded-lg py-1 pl-2.5 pr-1 text-sm font-medium ${
tag.invalid ? invalidChip : chipMap[accent]
}`}
>
<span className="max-w-[16rem] truncate">{tag.value}</span>
{tag.invalid ? (
<span className="sr-only">(invalid)</span>
) : null}
<button
type="button"
onClick={() => removeAt(tag.id, tag.value)}
aria-label={`Remove ${tag.value}`}
className={`grid h-5 w-5 place-items-center rounded-md text-current opacity-70 outline-none transition-colors hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current ${removeHoverMap[accent]}`}
>
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3 w-3"
>
<path
d="M5 5l10 10M15 5L5 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<input
id={inputId}
ref={inputRef}
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onBlur={() => commit(draft)}
disabled={atLimit && draft.length === 0}
placeholder={tags.length === 0 ? placeholder : atLimit ? "" : "Add another…"}
aria-describedby={`${hintId} ${statusId}`}
className="min-w-[10ch] flex-1 bg-transparent px-1.5 py-1 text-sm text-slate-900 outline-none placeholder:text-slate-400 disabled:cursor-not-allowed dark:text-slate-50 dark:placeholder:text-slate-500"
/>
</div>
<p id={hintId} className="text-xs text-slate-500 dark:text-slate-400">
{hint}
</p>
{openSuggestions.length > 0 && !atLimit ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-slate-400 dark:text-slate-500">
Suggested:
</span>
{openSuggestions.slice(0, 6).map((item) => (
<button
key={item}
type="button"
onClick={() => {
commit(item);
inputRef.current?.focus();
}}
aria-label={`Add ${item}`}
className={`inline-flex items-center gap-1 rounded-full border bg-transparent px-2.5 py-1 text-xs font-medium outline-none transition-colors focus-visible:ring-2 ${suggestMap[accent]}`}
>
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3 w-3"
>
<path
d="M10 4v12M4 10h12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
{item}
</button>
))}
</div>
) : null}
<span id={statusId} role="status" aria-live="polite" className="sr-only">
{status}
</span>
</div>
);
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function InpTags() {
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes inptags-shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-4px); }
40%, 60% { transform: translateX(4px); }
}
.inptags-shake-anim { animation: inptags-shake 0.32s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
@keyframes inptags-orb {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -22px, 0) scale(1.06); }
}
.inptags-orb { animation: inptags-orb 11s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.inptags-shake-anim { animation: none; }
.inptags-orb { animation: none; }
}
`}</style>
<div
aria-hidden="true"
className="inptags-orb pointer-events-none absolute -left-24 -top-24 h-80 w-80 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-32 -right-20 h-96 w-96 rounded-full bg-violet-400/15 blur-3xl dark:bg-violet-500/10"
/>
<div className="relative mx-auto max-w-2xl">
<header className="mb-10 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Tag input
</span>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Add and remove chips
</h2>
<p className="max-w-prose text-sm text-slate-600 dark:text-slate-300">
Type a value and press{" "}
<kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
Enter
</kbd>{" "}
or{" "}
<kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
,
</kbd>{" "}
to create a chip. Press{" "}
<kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
Backspace
</kbd>{" "}
on an empty field to delete the last one.
</p>
</header>
<div className="flex flex-col gap-8 rounded-2xl border border-slate-200 bg-white/60 p-6 shadow-xl shadow-slate-900/5 backdrop-blur sm:p-8 dark:border-slate-800 dark:bg-slate-900/40 dark:shadow-black/20">
<TagField
label="Skills"
accent="indigo"
placeholder="Add a skill, e.g. TypeScript"
hint="Paste a comma-separated list to add several at once."
initial={["TypeScript", "React", "Accessibility"]}
suggestions={[
"Node.js",
"GraphQL",
"Figma",
"Rust",
"PostgreSQL",
"Tailwind CSS",
"Testing",
]}
/>
<div className="h-px w-full bg-gradient-to-r from-transparent via-slate-200 to-transparent dark:via-slate-700" />
<TagField
label="Invite teammates"
accent="emerald"
placeholder="name@company.com"
hint="Invalid addresses are flagged in red — fix or remove them before sending."
initial={["maya@webinnoventix.com", "salman@"]}
maxTags={8}
validate={(value) => emailPattern.test(value)}
/>
<div className="h-px w-full bg-gradient-to-r from-transparent via-slate-200 to-transparent dark:via-slate-700" />
<TagField
label="Article topics"
accent="violet"
placeholder="Add a topic tag"
hint="Up to five topics keep the piece focused."
initial={["SEO", "Content strategy"]}
maxTags={5}
suggestions={["AIEO", "Core Web Vitals", "Backlinks", "Analytics"]}
/>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Set Input
Originaldefault, hover, focus, disabled, error text inputs

Floating Label Input
Originallabel that floats up on focus/fill

Filled Input
Originalfilled material-style inputs

Underline Input
Originalminimal underline inputs with animated bar

Search Input
Originalsearch box with icon and clear

Search Suggest Input
Originalsearch with a live suggestions dropdown

Password Toggle Input
Originalpassword field with show/hide toggle

Textarea Autosize Input
Originaltextarea that grows with content

Addon Group Input
Originalinput group with leading/trailing addons

Currency Input
Originalcurrency input with formatting

OTP Input
Original6-box one-time-code input with paste

Phone Input
Originalphone input with country prefix select

