Skip to content

Tag Input

Chips pop in and out with layout animation.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/tag-input.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/tag-input/tag-input.tsx
    "use client";
    
    import { useId, useRef, useState, type ComponentProps } from "react";
    import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
    import { X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { ease, spring } from "@/lib/motion";
    
    export interface TagInputProps
      extends Omit<
        ComponentProps<"input">,
        "value" | "defaultValue" | "onChange" | "size" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
      > {
      /** Controlled tags. */
      value?: string[];
      /** Initial tags when uncontrolled. @default [] */
      defaultValue?: string[];
      /** Called with the full list whenever a tag is added or removed. */
      onValueChange?: (tags: string[]) => void;
      /** Maximum number of tags; a counter appears when set. */
      maxTags?: number;
      /** Keys that turn the typed text into a tag. Single characters also split pasted text. @default ["Enter", ","] */
      separators?: string[];
      /** Return false to reject a tag. The field shakes and the text stays so it can be fixed. */
      validate?: (tag: string) => boolean;
      /** Allow the same tag more than once (case-insensitive). @default false */
      allowDuplicates?: boolean;
      /** Turn leftover text into a tag when the field loses focus. @default false */
      addOnBlur?: boolean;
      /** Classes for the outer field. */
      className?: string;
    }
    
    const SHAKE = { x: [0, -6, 6, -4, 4, 0] };
    
    export function TagInput({
      value,
      defaultValue = [],
      onValueChange,
      maxTags,
      separators = ["Enter", ","],
      validate,
      allowDuplicates = false,
      addOnBlur = false,
      className,
      placeholder = "Add a tag…",
      disabled,
      onKeyDown,
      onBlur,
      onPaste,
      "aria-describedby": describedBy,
      ...props
    }: TagInputProps) {
      const [internal, setInternal] = useState(defaultValue);
      const tags = value ?? internal;
      const [draft, setDraft] = useState("");
      const [armed, setArmed] = useState(false);
      const [announcement, setAnnouncement] = useState("");
      const [scope, animate] = useAnimate<HTMLDivElement>();
      const inputRef = useRef<HTMLInputElement>(null);
      const reduceMotion = useReducedMotion();
      const hintId = useId();
      const splitChars = separators.filter((s) => s.length === 1);
      const escaped = splitChars.map((ch) => ch.replace(/[\\\]^-]/g, "\\$&")).join("");
      // Single-character separators, newlines and tabs all split typed or pasted text.
      const splitPattern = new RegExp(`[\\n\\t${escaped}]+`);
      const full = maxTags !== undefined && tags.length >= maxTags;
    
      function commit(next: string[]) {
        if (value === undefined) setInternal(next);
        onValueChange?.(next);
      }
    
      function shake(element: Element | null | undefined) {
        if (!reduceMotion && element) animate(element, SHAKE, { duration: 0.35 });
      }
    
      /** Adds candidates; returns false if the last one was rejected so its text can stay. */
      function add(candidates: string[]) {
        const next = [...tags];
        let accepted = true;
        for (const raw of candidates) {
          const tag = raw.trim();
          if (!tag) continue;
          if (maxTags !== undefined && next.length >= maxTags) {
            shake(scope.current);
            setAnnouncement(`You can add up to ${maxTags} tags`);
            accepted = false;
            break;
          }
          const existing = next.findIndex((t) => t.toLowerCase() === tag.toLowerCase());
          if (!allowDuplicates && existing !== -1) {
            shake(scope.current?.querySelector(`[data-tag-index="${existing}"]`));
            setAnnouncement(`${tag} is already added`);
            continue;
          }
          if (validate && !validate(tag)) {
            shake(scope.current);
            setAnnouncement(`${tag} isn't valid`);
            accepted = false;
            continue;
          }
          next.push(tag);
          setAnnouncement(`Added ${tag}`);
        }
        if (next.length !== tags.length) commit(next);
        return accepted;
      }
    
      function remove(index: number) {
        const tag = tags[index];
        commit(tags.filter((_, i) => i !== index));
        setAnnouncement(`Removed ${tag}`);
        setArmed(false);
        inputRef.current?.focus();
      }
    
      function handleDraft(text: string) {
        const splitter = splitChars.find((ch) => text.includes(ch));
        if (!splitter) {
          setDraft(text);
          return;
        }
        // Typing (or a mobile keyboard) produced a separator: everything before the last one becomes tags.
        const parts = text.split(splitPattern);
        const rest = parts.pop() ?? "";
        const ok = add(parts);
        setDraft(ok ? rest : parts.join(splitter) + rest);
      }
    
      const occurrences = new Map<string, number>();
    
      return (
        <div
          ref={scope}
          onClick={(event) => {
            if (event.target === event.currentTarget) inputRef.current?.focus();
          }}
          className={cn(
            "flex min-h-10 w-full cursor-text flex-wrap items-center gap-1.5 rounded-md border border-input bg-background p-1.5 shadow-xs",
            "transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
            disabled && "pointer-events-none opacity-50",
            className,
          )}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            {tags.map((tag, index) => {
              const seen = occurrences.get(tag) ?? 0;
              occurrences.set(tag, seen + 1);
              const highlighted = armed && index === tags.length - 1;
              return (
                <motion.span
                  key={`${tag}::${seen}`}
                  data-tag-index={index}
                  layout={!reduceMotion}
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(4px)" }}
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={
                    reduceMotion
                      ? { opacity: 0, transition: { duration: 0.12 } }
                      : { opacity: 0, scale: 0.6, filter: "blur(4px)", transition: { duration: 0.16, ease: ease.out } }
                  }
                  transition={
                    reduceMotion
                      ? { duration: 0.12 }
                      : { ...spring.bouncy, opacity: { duration: 0.15 }, filter: { duration: 0.2 }, layout: spring.snappy }
                  }
                  className={cn(
                    "inline-flex h-7 max-w-full cursor-default items-center gap-0.5 rounded-[7px] border bg-secondary pr-0.5 pl-2.5 text-[13px] font-medium text-secondary-foreground transition-[border-color,box-shadow] duration-150",
                    highlighted ? "border-ring ring-2 ring-ring/25" : "border-border",
                  )}
                >
                  <span className="truncate">{tag}</span>
                  <button
                    type="button"
                    aria-label={`Remove ${tag}`}
                    onClick={() => remove(index)}
                    className="relative inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[5px] text-muted-foreground outline-none after:absolute after:-inset-1 hover:bg-foreground/[0.07] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    <X className="size-3" aria-hidden="true" />
                  </button>
                </motion.span>
              );
            })}
          </AnimatePresence>
    
          <motion.input
            ref={inputRef}
            layout={reduceMotion ? false : "position"}
            transition={spring.snappy}
            value={draft}
            disabled={disabled}
            placeholder={full ? "" : placeholder}
            aria-describedby={[hintId, describedBy].filter(Boolean).join(" ")}
            onChange={(event) => {
              setArmed(false);
              handleDraft(event.target.value);
            }}
            onKeyDown={(event) => {
              onKeyDown?.(event);
              if (event.defaultPrevented || event.nativeEvent.isComposing) return;
              if (event.key === "Enter" && separators.includes("Enter") && draft.trim()) {
                event.preventDefault();
                if (add([draft])) setDraft("");
              } else if (event.key === "Backspace" && draft === "" && tags.length > 0) {
                // First press highlights the last tag, the second removes it.
                event.preventDefault();
                if (armed) remove(tags.length - 1);
                else setArmed(true);
              } else if (armed && event.key !== "Shift") {
                setArmed(false);
              }
            }}
            onPaste={(event) => {
              onPaste?.(event);
              const text = event.clipboardData.getData("text");
              if (!splitPattern.test(text)) return;
              event.preventDefault();
              add(text.split(splitPattern));
            }}
            onBlur={(event) => {
              onBlur?.(event);
              setArmed(false);
              if (addOnBlur && draft.trim() && add([draft])) setDraft("");
            }}
            className="h-7 min-w-24 flex-1 bg-transparent px-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
            {...props}
          />
    
          {maxTags !== undefined ? (
            <span className="ml-auto shrink-0 px-1.5 font-mono text-[11px] text-muted-foreground tabular-nums" aria-hidden="true">
              {tags.length}/{maxTags}
            </span>
          ) : null}
          <span id={hintId} className="sr-only">
            Press Enter{splitChars.includes(",") ? " or comma" : ""} to add a tag. Press Backspace twice to remove the last tag.
            {maxTags !== undefined ? ` ${tags.length} of ${maxTags} tags used.` : ""}
          </span>
          <span role="status" className="sr-only">
            {announcement}
          </span>
        </div>
      );
    }

Usage

import { TagInput } from "@/components/ui/tag-input/tag-input";

export function SkillsField() {
  const [skills, setSkills] = useState(["React"]);
  return <TagInput aria-label="Skills" value={skills} onValueChange={setSkills} maxTags={8} />;
}

API reference

PropTypeDefaultDescription
valuestring[]–Controlled tags.
defaultValuestring[][]Initial tags when uncontrolled.
onValueChange(tags: string[]) => void–Called with the full list whenever a tag is added or removed.
maxTagsnumber–Maximum number of tags; a counter appears when set.
separatorsstring[]["Enter", ","]Keys that turn the typed text into a tag. Single characters also split pasted text.
validate(tag: string) => boolean–Return false to reject a tag. The field shakes and the text stays so it can be fixed.
allowDuplicatesbooleanfalseAllow the same tag more than once (case-insensitive).
addOnBlurbooleanfalseTurn leftover text into a tag when the field loses focus.
classNamestring–Classes for the outer field.

Also accepts every native <input> attribute, including ref and className.

  • A duplicate makes the existing tag shake instead of adding a copy. Set allowDuplicates to permit repeats.
  • When validate rejects a tag, the text stays in the field so the user can correct it.

Accessibility

  • Each tag has its own remove button labelled "Remove <tag>", reachable with Tab.
  • Backspace in an empty field first highlights the last tag and only removes it on a second press, so tags aren't deleted by accident.
  • A visually hidden hint explains the keys, and a status region announces added, removed, duplicate and rejected tags.
  • Separators work from typing, mobile keyboards and paste, so pasting "a, b, c" adds three tags.
  • With reduced motion enabled, tags fade in and out without scaling, and nothing shakes.