UIForms and Inputsmotion/react

TagInput

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
Press Enter or comma to add, Backspace to remove. 3/8

Customize

9 props
8

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add tag-input

Writes 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.

example.tsx
1import { TagInput } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <TagInput />6  );7}

Props

PropTypeDefault
value

Controlled list of tags.

string[]undefined
defaultValue

Initial tags when uncontrolled.

string[]['React', 'TypeScript', 'Motion']
onValueChange

Called with the next list whenever a tag is added or removed.

(tags: string[]) => voidundefined
label

Visible label and accessible name of the text box.

string'Skills'
placeholder

Placeholder shown in the text box.

string'Add a skill…'
maxTags

Maximum number of tags; 0 means unlimited. The input disables when the limit is reached.

number8
allowDuplicates

Allow the same tag more than once; otherwise the existing chip shakes.

booleanfalse
variant

Chip style.

'soft' | 'outline' | 'solid''soft'
size

Field and chip size preset.

'sm' | 'md' | 'lg''md'
accentColor

Chip tint and focus ring color.

string'#6366f1'
disabled

Disable the field and remove buttons.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/tag-input.tsx
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}