Overview

Typing plus chips — not a reorder list. New chips enter at scale 0.95 with a short stagger; exits soften with y: 4. Backspace removes the last chip when the field is empty; paste splits on commas.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/chip-input.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/chip-input.tsx
tsx
"use client";
import React, { useCallback, useRef, useState } from "react";import { X } from "lucide-react";import { cn } from "@/lib/utils";
export type ChipInputProps = {  value?: string[];  defaultValue?: string[];  onChange?: (value: string[]) => void;  placeholder?: string;  max?: number;  disabled?: boolean;  className?: string;};
function splitTokens(raw: string): string[] {  return raw    .split(/[,|\n]+/)    .map((s) => s.trim())    .filter(Boolean);}
export function ChipInput({  value: valueProp,  defaultValue = [],  onChange,  placeholder = "Add tag…",  max,  disabled,  className,}: ChipInputProps) {  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultValue);  const chips = isControlled ? valueProp : uncontrolled;  const [draft, setDraft] = useState("");  const inputRef = useRef<HTMLInputElement>(null);  const groupRef = useRef<HTMLDivElement>(null);
  const setChips = useCallback(    (next: string[]) => {      if (!isControlled) setUncontrolled(next);      onChange?.(next);    },    [isControlled, onChange],  );
  const setShifts = useCallback(    (activeIdx: number | null, phase: "in" | "out") => {      if (!groupRef.current) return;      const cs = getComputedStyle(document.documentElement);      const num = (name: string, fb: number) => {        const v = parseFloat(cs.getPropertyValue(name));        return Number.isFinite(v) ? v : fb;      };      const ease = (name: string, fb: string) =>        cs.getPropertyValue(name).trim() || fb;
      const lift = num("--avatar-lift", -4);      const falloff = num("--avatar-falloff", 0.45);      const scale = num("--avatar-scale", 1.05);      const tf =        phase === "out"          ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")          : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
      groupRef.current        .querySelectorAll<HTMLElement>(".t-avatar")        .forEach((el, i) => {          el.style.transitionTimingFunction = tf;          if (activeIdx == null) {            el.style.setProperty("--shift", "0px");            el.style.setProperty("--scale-active", "1");            return;          }          const d = Math.abs(i - activeIdx);          el.style.setProperty(            "--shift",            (lift * Math.pow(falloff, d)).toFixed(3) + "px",          );          el.style.setProperty(            "--scale-active",            i === activeIdx ? String(scale) : "1",          );        });    },    [],  );
  const addTokens = (tokens: string[]) => {    if (disabled || tokens.length === 0) return;    const seen = new Set(chips.map((c) => c.toLowerCase()));    const next = [...chips];    for (const t of tokens) {      if (max != null && next.length >= max) break;      if (seen.has(t.toLowerCase())) continue;      seen.add(t.toLowerCase());      next.push(t);    }    if (next.length !== chips.length) setChips(next);    setDraft("");  };
  const removeAt = (index: number) => {    if (disabled) return;    setChips(chips.filter((_, i) => i !== index));  };
  return (    <div      ref={groupRef}      className={cn(        "t-avatar-group flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-[var(--radius-md)] px-2.5 py-1.5",        "bg-[var(--color-paper)] ring-1 ring-[var(--color-rule)]",        "focus-within:ring-2 focus-within:ring-[var(--color-focus)]",        disabled && "cursor-not-allowed opacity-50",        className,      )}      onClick={() => inputRef.current?.focus()}      onMouseLeave={() => setShifts(null, "out")}    >      {chips.map((chip, i) => (        <span          key={chip}          className={cn(            "t-avatar inline-flex h-8 items-center gap-1 rounded-full pl-2.5 pr-1",            "bg-[var(--color-paper-2)] text-xs font-medium text-[var(--color-ink)]",          )}          onMouseEnter={() => setShifts(i, "in")}        >          {chip}          {!disabled ? (            <button              type="button"              aria-label={`Remove ${chip}`}              onClick={(e) => {                e.stopPropagation();                removeAt(i);              }}              className="flex size-6 items-center justify-center rounded-full outline-none hover:bg-[var(--color-rule)] focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] active:scale-[0.96]"            >              <X className="size-3.5" strokeWidth={1.75} aria-hidden />            </button>          ) : null}        </span>      ))}      <input        ref={inputRef}        value={draft}        disabled={disabled || (max != null && chips.length >= max)}        placeholder={chips.length === 0 ? placeholder : undefined}        onChange={(e) => setDraft(e.target.value)}        onKeyDown={(e) => {          if (e.key === "Enter" || e.key === ",") {            e.preventDefault();            addTokens(splitTokens(draft));          } else if (e.key === "Backspace" && draft === "" && chips.length) {            e.preventDefault();            removeAt(chips.length - 1);          }        }}        onPaste={(e) => {          const text = e.clipboardData.getData("text");          if (text.includes(",") || text.includes("\n")) {            e.preventDefault();            addTokens(splitTokens(text));          }        }}        onBlur={() => {          if (draft.trim()) addTokens(splitTokens(draft));        }}        className="min-w-[7rem] flex-1 bg-transparent py-1.5 text-sm text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-muted)]"      />    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ChipInput } from "@/components/ui/chip-input";
<ChipInput value={tags} onChange={setTags} placeholder="Add label…" max={8} />

Props

PropTypeRequiredDefaultDescription
valuestring[]NoControlled chip list.
onChange(value: string[]) => voidNoFires when chips are added or removed.
placeholderstringNoShown when the list is empty.
maxnumberNoOptional cap on chip count.
disabledbooleanNoDisables add/remove.

Best Practices

  1. Deduping is case-insensitive — keep labels short and consistent.
  2. Cap with max for ticket labels so the field doesn’t wrap endlessly.
  3. Prefer Enter/comma for commit; don’t rely only on blur.