Inputs & Forms

Tag Input

A chip-based input where tags commit on Enter or comma, animate in with scale and blur, and neighbors close the gap when one is removed.

Install

npx shadcn@latest add @paragon/tag-input

tag-input.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";

const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface TagInputProps
  extends Omit<
    React.ComponentProps<"input">,
    "value" | "defaultValue" | "onChange" | "type"
  > {
  /** Controlled list of tags. */
  value?: string[];
  /** Initial tags when uncontrolled. */
  defaultValue?: string[];
  /** Called with the next list whenever tags are added or removed. */
  onValueChange?: (tags: string[]) => void;
  /** Hard cap on the number of tags. */
  maxTags?: number;
}

/**
 * A text input that turns typed values into removable chips.
 * Enter or comma commits the draft; Backspace on an empty draft
 * highlights the last chip, then deletes it on the next press.
 */
export function TagInput({
  value: valueProp,
  defaultValue,
  onValueChange,
  maxTags,
  className,
  id,
  placeholder = "Add a tag…",
  disabled,
  onKeyDown,
  onBlur,
  onPaste,
  ...props
}: TagInputProps) {
  const reduced = useReducedMotion();
  const [uncontrolled, setUncontrolled] = React.useState<string[]>(
    defaultValue ?? [],
  );
  const tags = valueProp ?? uncontrolled;
  const [draft, setDraft] = React.useState("");
  // Whether the last chip is highlighted for pending deletion.
  const [marked, setMarked] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const setTags = (next: string[]) => {
    if (valueProp === undefined) setUncontrolled(next);
    onValueChange?.(next);
  };

  const addFromDraft = (raw: string) => {
    const parts = raw
      .split(",")
      .map((part) => part.trim())
      .filter(Boolean);
    if (parts.length === 0) return;
    const next = [...tags];
    for (const part of parts) {
      if (next.includes(part)) continue;
      if (maxTags !== undefined && next.length >= maxTags) break;
      next.push(part);
    }
    if (next.length !== tags.length) setTags(next);
    setDraft("");
  };

  const removeTag = (tag: string) => {
    setTags(tags.filter((t) => t !== tag));
    setMarked(false);
  };

  return (
    <div
      onClick={() => inputRef.current?.focus()}
      className={cn(
        "flex min-h-9 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-sm transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {tags.map((tag, i) => {
          const isMarked = marked && i === tags.length - 1;
          return (
            <motion.span
              key={tag}
              layout={!reduced}
              initial={
                reduced
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.9, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={
                reduced
                  ? { opacity: 0, transition: { duration: 0.15 } }
                  : {
                      opacity: 0,
                      scale: 0.9,
                      filter: "blur(4px)",
                      transition: { duration: 0.15, ease: EASE_EXIT },
                    }
              }
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className={cn(
                "inline-flex items-center gap-0.5 rounded-md py-0.5 pr-0.5 pl-2 text-xs font-medium transition-colors duration-150",
                isMarked
                  ? "bg-destructive/10 text-destructive"
                  : "bg-secondary text-secondary-foreground",
              )}
            >
              {tag}
              <button
                type="button"
                aria-label={`Remove ${tag}`}
                disabled={disabled}
                onClick={() => removeTag(tag)}
                className="relative flex size-4 items-center justify-center rounded-sm opacity-60 transition-opacity duration-100 after:absolute after:top-1/2 after:left-1/2 after:size-6 after:-translate-x-1/2 after:-translate-y-1/2 hover:opacity-100 focus-visible:opacity-100"
              >
                <X className="size-3" aria-hidden />
              </button>
            </motion.span>
          );
        })}
      </AnimatePresence>
      <input
        ref={inputRef}
        id={id}
        type="text"
        value={draft}
        disabled={disabled}
        placeholder={tags.length === 0 ? placeholder : ""}
        onChange={(e) => {
          setDraft(e.target.value);
          setMarked(false);
        }}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.defaultPrevented || e.nativeEvent.isComposing) return;
          if (e.key === "Enter" || e.key === ",") {
            e.preventDefault();
            addFromDraft(draft);
          } else if (e.key === "Backspace" && draft === "" && tags.length > 0) {
            e.preventDefault();
            if (marked) removeTag(tags[tags.length - 1]);
            else setMarked(true);
          } else if (e.key === "Escape") {
            setMarked(false);
          }
        }}
        onPaste={(e) => {
          onPaste?.(e);
          if (e.defaultPrevented) return;
          const text = e.clipboardData.getData("text");
          if (text.includes(",")) {
            e.preventDefault();
            addFromDraft(draft + text);
          }
        }}
        onBlur={(e) => {
          setMarked(false);
          onBlur?.(e);
        }}
        className="min-w-20 flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
        {...props}
      />
    </div>
  );
}