# Tag Input

Type and press Enter or comma to add chips. Backspace highlights the last chip first, then removes it on the second press.

**Category:** Inputs  
**Exports:** `TagInput`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/tag-input.json
```
The file lands at `components/ui/tag-input.tsx`; import from `@/components/ui/tag-input`.

## API — Tag Input
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value / defaultValue` | `string[]` | — |  |
| `onValueChange` | `(tags: string[]) => void` | — |  |
| `label` | `string` | — |  |
| `max` | `number` | `Infinity` |  |
| `placeholder` | `string` | — |  |

## Example — Tag Input

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

export default function TagInputDemo({ label = "Skills", placeholder = "Add a tag…", max = 8 }: { label?: string; placeholder?: string; max?: number }) {
  return (
    <div className="w-full max-w-sm">
      <TagInput label={label} placeholder={placeholder} defaultValue={["React", "Motion", "Tailwind"]} max={max} />
    </div>
  );
}
```

## Source — components/ui/tag-input.tsx

```tsx
"use client";

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

export type TagInputProps = {
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (tags: string[]) => void;
  label?: string;
  placeholder?: string;
  /** Maximum number of tags. */
  max?: number;
  className?: string;
};

export function TagInput({ value: valueProp, defaultValue = [], onValueChange, label, placeholder = "Add a tag…", max = Infinity, className }: TagInputProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [inner, setInner] = React.useState(defaultValue);
  const tags = valueProp ?? inner;
  const [draft, setDraft] = React.useState("");
  const [armed, setArmed] = React.useState(false);
  const [message, setMessage] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);

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

  const add = (raw: string) => {
    const t = raw.trim().replace(/,$/, "");
    if (!t) return;
    if (tags.some((x) => x.toLowerCase() === t.toLowerCase())) return setMessage(`“${t}” is already added`);
    if (tags.length >= max) return setMessage(`You can add up to ${max} tags`);
    commit([...tags, t]);
    setDraft("");
    setMessage(`Added ${t}`);
  };

  const remove = (t: string) => {
    commit(tags.filter((x) => x !== t));
    setMessage(`Removed ${t}`);
    inputRef.current?.focus();
  };

  return (
    <div className={cn("grid w-full gap-1.5", className)}>
      {label && (
        <label htmlFor={id} className="text-sm font-medium">
          {label}
        </label>
      )}
      <div
        onClick={() => inputRef.current?.focus()}
        className="flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-input bg-background px-2 py-1.5 transition-[border-color,box-shadow] focus-within:border-foreground/40 focus-within:ring-[3px] focus-within:ring-ring/25"
      >
        <ul className="contents" aria-label="Tags">
          <AnimatePresence initial={false} mode="popLayout">
            {tags.map((t, i) => (
              <motion.li
                key={t}
                layout={!reduce}
                initial={{ opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}
                transition={{ type: "spring", stiffness: 500, damping: 30 }}
                className={cn(
                  "inline-flex h-7 items-center gap-1 rounded-md pl-2.5 pr-1 text-[13px] font-medium transition-colors",
                  armed && i === tags.length - 1 ? "bg-destructive/15 text-destructive" : "bg-secondary text-secondary-foreground",
                )}
              >
                {t}
                <button
                  type="button"
                  aria-label={`Remove ${t}`}
                  onClick={(e) => {
                    e.stopPropagation();
                    remove(t);
                  }}
                  className="grid size-5 cursor-pointer place-items-center rounded text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <svg viewBox="0 0 24 24" className="size-3" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" aria-hidden>
                    <path d="M6 6l12 12M18 6 6 18" />
                  </svg>
                </button>
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>
        <input
          ref={inputRef}
          id={id}
          value={draft}
          placeholder={tags.length ? "" : placeholder}
          onChange={(e) => {
            setArmed(false);
            if (e.target.value.endsWith(",")) add(e.target.value);
            else setDraft(e.target.value);
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
              add(draft);
            } else if (e.key === "Backspace" && !draft && tags.length) {
              // First Backspace highlights the last tag, the second removes it.
              if (armed) {
                remove(tags[tags.length - 1]);
                setArmed(false);
              } else setArmed(true);
            } else setArmed(false);
          }}
          onBlur={() => {
            setArmed(false);
            if (draft) add(draft);
          }}
          className="h-7 min-w-24 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-sm"
        />
      </div>
      <p className="text-[13px] text-muted-foreground">Press Enter or comma to add.</p>
      <span className="sr-only" aria-live="polite">
        {message}
      </span>
    </div>
  );
}
```