# Multi-Select Tag Input

> Add a tag and it pops in; remove one and the gap closes cleanly.

A shadcn/ui block from the [@wensity](https://ui.wensity.com/components) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/wensity/multi-select-token-pills).

- Page, with a live preview: https://shadcn.exchange/r/wensity/multi-select-token-pills
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/wensity/multi-select-token-pills-light-f46a88398961baed.png
- Registry item: https://raw.githubusercontent.com/wensity/registry/main/multi-select-token-pills.json
- Categories: Elite Micro-Interactions, wensity, free
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://raw.githubusercontent.com/wensity/registry/main/multi-select-token-pills.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tabler/icons-react`, `clsx`, `framer-motion`, `tailwind-merge`

## Notes from the registry

Free Wensity component. Installs to @components/wensity/multi-select-token-pills.tsx. For Pro components and updates, use pnpm dlx wensity@latest add multi-select-token-pills.

## Files

### `registry/wensity/lib/utils.ts`

```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### `registry/wensity/multi-select-token-pills.tsx`

```tsx
"use client";

import * as React from "react";
import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion";
import { IconX } from "@tabler/icons-react";
import { cn } from "@/lib/utils";

export interface TokenPill {
  id: string;
  /** Visible label. */
  label: string;
  /** Optional secondary value (e.g., the parsed email when label is the display name). */
  value?: string;
  /** Render as invalid (red ring + accent). */
  invalid?: boolean;
}

export interface MultiSelectTokenPillsProps {
  /** Controlled tokens. */
  value?: TokenPill[];
  /** Default for uncontrolled usage. */
  defaultValue?: TokenPill[];
  /** Notified on every add/remove. */
  onChange?: (next: TokenPill[]) => void;
  /** Placeholder for the input. */
  placeholder?: string;
  /** Validate a freshly-typed token before it is committed. Return an
   *  object with `invalid: true` to keep the pill but mark it red. */
  validate?: (raw: string) => { invalid?: boolean } | void;
  /** Characters that commit the current input as a token. Defaults to
   *  comma, semicolon, space, Enter, Tab. */
  delimiters?: RegExp;
  /** Maximum number of tokens. Adding past this is a no-op. */
  max?: number;
  className?: string;
  /** Optional label rendered above the field. */
  label?: string;
  /** Disable the entire control. */
  disabled?: boolean;
}

const itemVariants: Variants = {
  hidden: { opacity: 0, scale: 0.8, y: -4 },
  show: {
    opacity: 1,
    scale: 1,
    y: 0,
    transition: { type: "spring", stiffness: 460, damping: 26 },
  },
  // The width/margin collapse on exit is the key — adjacent flex children
  // glide naturally into the gap instead of snapping.
  exit: {
    opacity: 0,
    scale: 0.8,
    width: 0,
    marginRight: 0,
    paddingLeft: 0,
    paddingRight: 0,
    transition: { duration: 0.22, ease: [0.7, 0, 0.84, 0] },
  },
};

/**
 * MultiSelectTokenPills
 *
 * A tag/chip input that animates additions with a spring pop-in and exits
 * by physically collapsing each pill's `width` + horizontal margin to 0 —
 * so siblings glide over to fill the gap (no snap, no layout flicker).
 *
 * Pasting a comma-separated list ripples the new pills in via Framer's
 * `staggerChildren`, like dominoes — the new tokens get a fresh `addBatch`
 * id so AnimatePresence can stagger them as a single wave.
 *
 * GPU contract: animates only `transform` + `opacity` (and exit-only
 * `width`/`margin` which we accept as the trade-off for sibling glide).
 */
export function MultiSelectTokenPills({
  value: controlled,
  defaultValue = [],
  onChange,
  placeholder = "Add a tag…",
  validate,
  delimiters = /[,;\s]+/,
  max,
  className,
  label,
  disabled = false,
}: MultiSelectTokenPillsProps) {
  const isControlled = controlled !== undefined;
  const [internal, setInternal] = React.useState<TokenPill[]>(defaultValue);
  const tokens = isControlled ? (controlled as TokenPill[]) : internal;

  const [draft, setDraft] = React.useState("");
  // Tracks the most recent batch of additions so we can stagger them.
  const [, setLastBatchAt] = React.useState(0);
  const reduce = useReducedMotion();
  const inputRef = React.useRef<HTMLInputElement>(null);

  const commit = React.useCallback(
    (raw: string) => {
      const parts = raw
        .split(delimiters)
        .map((p) => p.trim())
        .filter(Boolean);
      if (!parts.length) return;

      const existing = new Set(tokens.map((t) => t.label.toLowerCase()));
      const additions: TokenPill[] = [];
      for (const p of parts) {
        if (max !== undefined && tokens.length + additions.length >= max) break;
        if (existing.has(p.toLowerCase())) continue;
        existing.add(p.toLowerCase());
        const v = validate?.(p);
        additions.push({
          id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
          label: p,
          invalid: v?.invalid,
        });
      }
      if (!additions.length) return;
      const next = [...tokens, ...additions];
      if (!isControlled) setInternal(next);
      onChange?.(next);
      setLastBatchAt(Date.now());
    },
    [tokens, delimiters, max, validate, isControlled, onChange]
  );

  function remove(id: string) {
    const next = tokens.filter((t) => t.id !== id);
    if (!isControlled) setInternal(next);
    onChange?.(next);
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (disabled) return;
    if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
      if (draft.trim()) {
        e.preventDefault();
        commit(draft);
        setDraft("");
      }
    } else if (e.key === "Backspace" && !draft && tokens.length) {
      e.preventDefault();
      remove(tokens[tokens.length - 1].id);
    }
  }

  function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
    if (disabled) return;
    const text = e.clipboardData.getData("text");
    if (!text) return;
    if (delimiters.test(text)) {
      e.preventDefault();
      commit(text);
      setDraft("");
    }
  }

  return (
    <div className={cn("w-full", className)}>
      {label && (
        <label className="mb-2 block text-[12.5px] font-medium tracking-tight text-[var(--foreground)]">
          {label}
        </label>
      )}
      <motion.div
        onClick={() => inputRef.current?.focus()}
        className={cn(
          "group relative flex min-h-[44px] w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5",
          "transition-colors duration-200 focus-within:border-chili-500/50 focus-within:ring-2 focus-within:ring-chili-500/20",
          disabled && "pointer-events-none opacity-60"
        )}
        // Container variants run on every batch so newly-added children
        // ripple in via stagger.
        variants={{
          show: {
            transition: { staggerChildren: reduce ? 0 : 0.05 },
          },
        }}
        initial={false}
        animate="show"
      >
        <AnimatePresence initial={false}>
          {tokens.map((t) => (
            <motion.span
              key={t.id}
              layout
              variants={itemVariants}
              initial="hidden"
              animate="show"
              exit="exit"
              className={cn(
                "inline-flex items-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-1 text-[12px] font-medium tracking-tight",
                t.invalid
                  // Invalid is a semantic error state — uses true error red
                  // (#ef4444), distinct from the brand chili. Otherwise every
                  // valid + invalid token would carry the same brand mark.
                  ? "border-[#ef4444]/40 bg-[#ef4444]/10 text-[#ef4444]"
                  : "border-[var(--border)] bg-[var(--surface-muted)] text-[var(--foreground)]"
              )}
              style={{ willChange: "transform, opacity, width" }}
            >
              <span className="truncate max-w-[200px]">{t.label}</span>
              <button
                type="button"
                aria-label={`Remove ${t.label}`}
                onClick={(e) => {
                  e.stopPropagation();
                  remove(t.id);
                }}
                className="grid h-4 w-4 shrink-0 place-items-center rounded-full text-[var(--muted-foreground)] transition-colors hover:bg-[var(--border)] hover:text-[var(--foreground)]"
              >
                <IconX size={11} stroke={2.4} />
              </button>
            </motion.span>
          ))}
        </AnimatePresence>

        <input
          ref={inputRef}
          type="text"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={handleKeyDown}
          onPaste={handlePaste}
          onBlur={() => {
            if (draft.trim()) {
              commit(draft);
              setDraft("");
            }
          }}
          placeholder={tokens.length === 0 ? placeholder : ""}
          disabled={disabled}
          className="min-w-[120px] flex-1 bg-transparent px-1.5 py-1 text-[13px] text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
        />
      </motion.div>
    </div>
  );
}
```
