A text box that turns entries into chips: Enter or comma adds a tag that pops in while its neighbours slide aside, Backspace arms then removes the last chip, duplicates shake the existing chip, and pasted comma lists are split automatically.
Preview
- React
- TypeScript
- Motion
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add tag-inputWrites components/ui/tag-input.tsx and components/styles/tag-input.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { TagInput } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <TagInput />6 );7}Props
Accessibility
A native text input labelled by a visible <label> and described by a keyboard hint. Chips are list items in a labelled list (with the tag count); each has a native remove button labelled 'Remove <tag>' that also responds to Backspace/Delete. Backspace in an empty input first highlights the last chip, then removes it; Escape clears the draft. Additions and removals are announced through a polite live region and focus returns to the input after removal. Under prefers-reduced-motion chips fade without scaling or layout sliding.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/tag-input.module.css";6 7export interface TagInputProps {8 /** Controlled list of tags */9 value?: string[];10 /** Initial tags when uncontrolled */11 defaultValue?: string[];12 /** Called with the next list whenever a tag is added or removed */13 onValueChange?: (tags: string[]) => void;14 /** Visible label (also the accessible name of the text box) */15 label?: string;16 /** Placeholder shown in the text box */17 placeholder?: string;18 /** Maximum number of tags (0 = unlimited) */19 maxTags?: number;20 /** Allow the same tag more than once (case-insensitive check) */21 allowDuplicates?: boolean;22 /** Chip style */23 variant?: "soft" | "outline" | "solid";24 /** Size preset */25 size?: "sm" | "md" | "lg";26 /** Chip and focus accent color */27 accentColor?: string;28 /** Disable the field */29 disabled?: boolean;30 /** Optional className merged onto the root */31 className?: string;32 /** Optional inline styles merged onto the root */33 style?: React.CSSProperties;34}35 36const DEFAULT_TAGS = ["React", "TypeScript", "Motion"];37 38export function TagInput({39 value: valueProp,40 defaultValue = DEFAULT_TAGS,41 onValueChange,42 label = "Skills",43 placeholder = "Add a skill…",44 maxTags = 8,45 allowDuplicates = false,46 variant = "soft",47 size = "md",48 accentColor = "#6366f1",49 disabled = false,50 className = "",51 style,52}: TagInputProps) {53 const reduce = useReducedMotion();54 const [inner, setInner] = React.useState<string[]>(defaultValue);55 const tags = valueProp ?? inner;56 const [draft, setDraft] = React.useState("");57 const [armed, setArmed] = React.useState(false);58 const [flash, setFlash] = React.useState<{ index: number; n: number } | null>(null);59 const [announce, setAnnounce] = React.useState("");60 const inputRef = React.useRef<HTMLInputElement>(null);61 const id = React.useId();62 const full = maxTags > 0 && tags.length >= maxTags;63 64 React.useEffect(() => {65 if (!flash) return;66 const t = window.setTimeout(() => setFlash(null), 420);67 return () => window.clearTimeout(t);68 }, [flash]);69 70 const seen = new Map<string, number>();71 const keys = tags.map((tag) => {72 const n = seen.get(tag) ?? 0;73 seen.set(tag, n + 1);74 return `${tag}#${n}`;75 });76 77 const update = (next: string[]) => {78 if (valueProp === undefined) setInner(next);79 onValueChange?.(next);80 };81 82 const add = (raw: string) => {83 const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);84 if (parts.length === 0) return;85 const next = [...tags];86 const added: string[] = [];87 for (const part of parts) {88 if (maxTags > 0 && next.length >= maxTags) break;89 const dup = next.findIndex((t) => t.toLowerCase() === part.toLowerCase());90 if (dup !== -1 && !allowDuplicates) {91 setFlash((f) => ({ index: dup, n: (f?.n ?? 0) + 1 }));92 continue;93 }94 next.push(part);95 added.push(part);96 }97 if (added.length) {98 update(next);99 setAnnounce(`Added ${added.join(", ")}`);100 }101 setDraft("");102 };103 104 const remove = (index: number, refocus = true) => {105 const tag = tags[index];106 if (tag === undefined) return;107 update(tags.filter((_, i) => i !== index));108 setAnnounce(`Removed ${tag}`);109 setArmed(false);110 if (refocus) inputRef.current?.focus();111 };112 113 const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {114 if (e.key === "Enter" || e.key === ",") {115 if (draft.trim() || e.key === ",") e.preventDefault();116 add(draft);117 } else if (e.key === "Backspace" && draft === "" && tags.length > 0) {118 e.preventDefault();119 if (armed) remove(tags.length - 1);120 else setArmed(true);121 } else if (e.key === "Escape") {122 setArmed(false);123 setDraft("");124 } else {125 setArmed(false);126 }127 };128 129 const spring = reduce ? { duration: 0.1 } : { type: "spring" as const, stiffness: 520, damping: 32 };130 131 return (132 <div133 className={`${styles.root} ${className}`.trim()}134 data-variant={variant}135 data-size={size}136 data-disabled={disabled ? "" : undefined}137 style={{ "--mf-tag-accent": accentColor, ...style } as React.CSSProperties}138 >139 <label htmlFor={id} className={styles.label}>140 {label}141 </label>142 <div className={styles.box} onPointerDown={(e) => {143 if (disabled || (e.target instanceof Element && e.target.closest("button, input"))) return;144 e.preventDefault();145 inputRef.current?.focus();146 }}>147 <ul role="list" className={styles.list} aria-label={`${label}: ${tags.length} selected`}>148 <AnimatePresence initial={false} mode="popLayout">149 {tags.map((tag, i) => (150 <motion.li151 key={keys[i]}152 layout={!reduce}153 className={styles.chip}154 data-armed={armed && i === tags.length - 1 ? "" : undefined}155 initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}156 animate={flash?.index === i && !reduce ? { opacity: 1, scale: 1, x: [0, -4, 4, -2, 0] } : { opacity: 1, scale: 1, x: 0 }}157 exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.14 } }}158 transition={spring}159 >160 <span className={styles.text}>{tag}</span>161 <button162 type="button"163 className={styles.remove}164 aria-label={`Remove ${tag}`}165 disabled={disabled}166 onClick={() => remove(i)}167 onKeyDown={(e) => {168 if (e.key === "Backspace" || e.key === "Delete") {169 e.preventDefault();170 remove(i);171 }172 }}173 >174 <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">175 <path d="M18 6 6 18M6 6l12 12" />176 </svg>177 </button>178 </motion.li>179 ))}180 </AnimatePresence>181 </ul>182 <input183 ref={inputRef}184 id={id}185 className={styles.input}186 value={draft}187 disabled={disabled || full}188 placeholder={full ? `Limit of ${maxTags} reached` : placeholder}189 aria-describedby={`${id}-hint`}190 onChange={(e) => {191 const v = e.target.value;192 if (v.includes(",")) add(v);193 else setDraft(v);194 }}195 onKeyDown={onKeyDown}196 onBlur={() => {197 setArmed(false);198 if (draft.trim()) add(draft);199 }}200 />201 </div>202 <span id={`${id}-hint`} className={styles.hint}>203 Press Enter or comma to add, Backspace to remove.204 {maxTags > 0 ? ` ${tags.length}/${maxTags}` : ""}205 </span>206 <span className={styles.srOnly} aria-live="polite">207 {announce}208 </span>209 </div>210 );211}