Tag Input
A field that turns what you type into tags. Enter or a comma adds one, Backspace marks then removes the last, and a duplicate shakes the tag that already exists.
- react
- motion
- tailwind
Enter or a comma adds a tag. Backspace twice removes the last one. Try adding react again.
"use client";
import { TagInput } from "@/components/motion/tag-input";
export function TagInputPreview() {
return (
<div className="flex w-full max-w-md flex-col gap-2">
<TagInput defaultValue={["react", "motion", "tailwind"]} max={8} label="Topics" placeholder="Add a topic" />
<p className="text-xs text-muted-foreground">Enter or a comma adds a tag. Backspace twice removes the last one. Try adding react again.</p>
</div>
);
}
"use client";
// easeui.dev/components/motion/tag-input
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type KeyboardEvent, useId, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface TagInputProps {
/** Controlled list of tags. */
value?: string[];
/** Starting tags when uncontrolled. */
defaultValue?: string[];
onValueChange?: (tags: string[]) => void;
placeholder?: string;
/** Stops adding once this many tags exist. */
max?: number;
/** Names the field for screen readers. Default "Tags". */
label?: string;
className?: string;
}
/**
* A field that turns what you type into tags. Enter or a comma adds one, Backspace on an empty
* field marks the last tag then removes it, and a duplicate shakes the tag that already exists.
*/
export function TagInput({ value, defaultValue = [], onValueChange, placeholder = "Add a tag", max, label = "Tags", className }: TagInputProps) {
const reduce = useReducedMotion();
const id = useId();
const input = useRef<HTMLInputElement>(null);
const [inner, setInner] = useState(defaultValue);
const tags = value ?? inner;
const [draft, setDraft] = useState("");
const [marked, setMarked] = useState(false);
const [shake, setShake] = useState<{ tag: string; n: number } | null>(null);
const full = max !== undefined && tags.length >= max;
const commit = (next: string[]) => {
setInner(next);
onValueChange?.(next);
};
const add = () => {
const tag = draft.trim().replace(/,$/, "").trim();
if (!tag || full) return;
const existing = tags.find((t) => t.toLowerCase() === tag.toLowerCase());
if (existing) {
setShake((s) => ({ tag: existing, n: (s?.n ?? 0) + 1 }));
return;
}
commit([...tags, tag]);
setDraft("");
};
const remove = (tag: string) => {
commit(tags.filter((t) => t !== tag));
setMarked(false);
input.current?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
add();
} else if (event.key === "Backspace" && draft === "" && tags.length) {
// The first press marks the last tag, the second removes it, so one slip doesn't lose it.
if (marked) remove(tags[tags.length - 1]);
else setMarked(true);
} else if (marked) {
setMarked(false);
}
};
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: clicking the padding focuses the input, which has its own keyboard handling.
// biome-ignore lint/a11y/noStaticElementInteractions: the input inside is the real control.
<div
onClick={() => input.current?.focus()}
className={cn(
"flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl bg-background px-2 py-1.5 shadow-[0_0_0_1px_var(--border-strong)]",
"transition-shadow duration-150 focus-within:shadow-[0_0_0_2px_var(--accent)]",
className,
)}
>
<label htmlFor={id} className="sr-only">
{label}
</label>
<ul className="contents">
<AnimatePresence initial={false} mode="popLayout">
{tags.map((tag, index) => {
const isMarked = marked && index === tags.length - 1;
const shaking = shake?.tag === tag;
return (
<motion.li
key={tag}
layout={!reduce}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
animate={
shaking && !reduce
? { opacity: 1, scale: 1, x: [0, -4, 4, -3, 3, 0] }
: { opacity: 1, scale: 1, x: 0 }
}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2, ease: EASE_OUT, x: { duration: 0.32 } }}
onAnimationComplete={() => shaking && setShake(null)}
className={cn(
"flex h-7 items-center gap-1 rounded-lg pl-2.5 pr-1 text-sm transition-colors duration-150",
isMarked ? "bg-accent text-accent-foreground" : "bg-muted text-foreground shadow-[inset_0_0_0_1px_var(--border)]",
)}
>
{tag}
<button
type="button"
aria-label={`Remove ${tag}`}
onClick={(event) => {
event.stopPropagation();
remove(tag);
}}
className="relative grid h-5 w-5 place-items-center rounded-md opacity-60 outline-none transition-opacity duration-150 after:absolute after:-inset-1.5 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-foreground/40"
>
<X aria-hidden="true" className="h-3 w-3" />
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
<input
ref={input}
id={id}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setMarked(false);
}}
onKeyDown={onKeyDown}
onBlur={() => setMarked(false)}
disabled={full}
placeholder={full ? `Up to ${max} tags` : tags.length ? "" : placeholder}
className="h-7 min-w-24 flex-1 bg-transparent px-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm"
/>
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/tag-input
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/tag-input.tsx "use client"; // easeui.dev/components/motion/tag-input import { X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, useId, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface TagInputProps { /** Controlled list of tags. */ value?: string[]; /** Starting tags when uncontrolled. */ defaultValue?: string[]; onValueChange?: (tags: string[]) => void; placeholder?: string; /** Stops adding once this many tags exist. */ max?: number; /** Names the field for screen readers. Default "Tags". */ label?: string; className?: string; } /** * A field that turns what you type into tags. Enter or a comma adds one, Backspace on an empty * field marks the last tag then removes it, and a duplicate shakes the tag that already exists. */ export function TagInput({ value, defaultValue = [], onValueChange, placeholder = "Add a tag", max, label = "Tags", className }: TagInputProps) { const reduce = useReducedMotion(); const id = useId(); const input = useRef<HTMLInputElement>(null); const [inner, setInner] = useState(defaultValue); const tags = value ?? inner; const [draft, setDraft] = useState(""); const [marked, setMarked] = useState(false); const [shake, setShake] = useState<{ tag: string; n: number } | null>(null); const full = max !== undefined && tags.length >= max; const commit = (next: string[]) => { setInner(next); onValueChange?.(next); }; const add = () => { const tag = draft.trim().replace(/,$/, "").trim(); if (!tag || full) return; const existing = tags.find((t) => t.toLowerCase() === tag.toLowerCase()); if (existing) { setShake((s) => ({ tag: existing, n: (s?.n ?? 0) + 1 })); return; } commit([...tags, tag]); setDraft(""); }; const remove = (tag: string) => { commit(tags.filter((t) => t !== tag)); setMarked(false); input.current?.focus(); }; const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); add(); } else if (event.key === "Backspace" && draft === "" && tags.length) { // The first press marks the last tag, the second removes it, so one slip doesn't lose it. if (marked) remove(tags[tags.length - 1]); else setMarked(true); } else if (marked) { setMarked(false); } }; return ( // biome-ignore lint/a11y/useKeyWithClickEvents: clicking the padding focuses the input, which has its own keyboard handling. // biome-ignore lint/a11y/noStaticElementInteractions: the input inside is the real control. <div onClick={() => input.current?.focus()} className={cn( "flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl bg-background px-2 py-1.5 shadow-[0_0_0_1px_var(--border-strong)]", "transition-shadow duration-150 focus-within:shadow-[0_0_0_2px_var(--accent)]", className, )} > <label htmlFor={id} className="sr-only"> {label} </label> <ul className="contents"> <AnimatePresence initial={false} mode="popLayout"> {tags.map((tag, index) => { const isMarked = marked && index === tags.length - 1; const shaking = shake?.tag === tag; return ( <motion.li key={tag} layout={!reduce} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }} animate={ shaking && !reduce ? { opacity: 1, scale: 1, x: [0, -4, 4, -3, 3, 0] } : { opacity: 1, scale: 1, x: 0 } } exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }} transition={{ duration: 0.2, ease: EASE_OUT, x: { duration: 0.32 } }} onAnimationComplete={() => shaking && setShake(null)} className={cn( "flex h-7 items-center gap-1 rounded-lg pl-2.5 pr-1 text-sm transition-colors duration-150", isMarked ? "bg-accent text-accent-foreground" : "bg-muted text-foreground shadow-[inset_0_0_0_1px_var(--border)]", )} > {tag} <button type="button" aria-label={`Remove ${tag}`} onClick={(event) => { event.stopPropagation(); remove(tag); }} className="relative grid h-5 w-5 place-items-center rounded-md opacity-60 outline-none transition-opacity duration-150 after:absolute after:-inset-1.5 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-foreground/40" > <X aria-hidden="true" className="h-3 w-3" /> </button> </motion.li> ); })} </AnimatePresence> </ul> <input ref={input} id={id} value={draft} onChange={(event) => { setDraft(event.target.value); setMarked(false); }} onKeyDown={onKeyDown} onBlur={() => setMarked(false)} disabled={full} placeholder={full ? `Up to ${max} tags` : tags.length ? "" : placeholder} className="h-7 min-w-24 flex-1 bg-transparent px-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm" /> </div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; /** CSS string form of EASE_OUT for inline style transitions. */ export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value? | {} | - | Controlled list of tags. |
| defaultValue? | {} | [] | Starting tags when uncontrolled. |
| onValueChange? | ((tags: {}) => void) | - | - |
| placeholder? | string | Add a tag | - |
| max? | number | - | Stops adding once this many tags exist. |
| label? | string | Tags | Names the field for screen readers. Default "Tags". |
| className? | string | - | - |