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.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useRef, useState } from "react";4import { X } from "lucide-react";5import { cn } from "@/lib/utils";6 7export type ChipInputProps = {8 value?: string[];9 defaultValue?: string[];10 onChange?: (value: string[]) => void;11 placeholder?: string;12 max?: number;13 disabled?: boolean;14 className?: string;15};16 17function splitTokens(raw: string): string[] {18 return raw19 .split(/[,|\n]+/)20 .map((s) => s.trim())21 .filter(Boolean);22}23 24export function ChipInput({25 value: valueProp,26 defaultValue = [],27 onChange,28 placeholder = "Add tag…",29 max,30 disabled,31 className,32}: ChipInputProps) {33 const isControlled = valueProp !== undefined;34 const [uncontrolled, setUncontrolled] = useState(defaultValue);35 const chips = isControlled ? valueProp : uncontrolled;36 const [draft, setDraft] = useState("");37 const inputRef = useRef<HTMLInputElement>(null);38 const groupRef = useRef<HTMLDivElement>(null);39 40 const setChips = useCallback(41 (next: string[]) => {42 if (!isControlled) setUncontrolled(next);43 onChange?.(next);44 },45 [isControlled, onChange],46 );47 48 const setShifts = useCallback(49 (activeIdx: number | null, phase: "in" | "out") => {50 if (!groupRef.current) return;51 const cs = getComputedStyle(document.documentElement);52 const num = (name: string, fb: number) => {53 const v = parseFloat(cs.getPropertyValue(name));54 return Number.isFinite(v) ? v : fb;55 };56 const ease = (name: string, fb: string) =>57 cs.getPropertyValue(name).trim() || fb;58 59 const lift = num("--avatar-lift", -4);60 const falloff = num("--avatar-falloff", 0.45);61 const scale = num("--avatar-scale", 1.05);62 const tf =63 phase === "out"64 ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")65 : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");66 67 groupRef.current68 .querySelectorAll<HTMLElement>(".t-avatar")69 .forEach((el, i) => {70 el.style.transitionTimingFunction = tf;71 if (activeIdx == null) {72 el.style.setProperty("--shift", "0px");73 el.style.setProperty("--scale-active", "1");74 return;75 }76 const d = Math.abs(i - activeIdx);77 el.style.setProperty(78 "--shift",79 (lift * Math.pow(falloff, d)).toFixed(3) + "px",80 );81 el.style.setProperty(82 "--scale-active",83 i === activeIdx ? String(scale) : "1",84 );85 });86 },87 [],88 );89 90 const addTokens = (tokens: string[]) => {91 if (disabled || tokens.length === 0) return;92 const seen = new Set(chips.map((c) => c.toLowerCase()));93 const next = [...chips];94 for (const t of tokens) {95 if (max != null && next.length >= max) break;96 if (seen.has(t.toLowerCase())) continue;97 seen.add(t.toLowerCase());98 next.push(t);99 }100 if (next.length !== chips.length) setChips(next);101 setDraft("");102 };103 104 const removeAt = (index: number) => {105 if (disabled) return;106 setChips(chips.filter((_, i) => i !== index));107 };108 109 return (110 <div111 ref={groupRef}112 className={cn(113 "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",114 "bg-[var(--color-paper)] ring-1 ring-[var(--color-rule)]",115 "focus-within:ring-2 focus-within:ring-[var(--color-focus)]",116 disabled && "cursor-not-allowed opacity-50",117 className,118 )}119 onClick={() => inputRef.current?.focus()}120 onMouseLeave={() => setShifts(null, "out")}121 >122 {chips.map((chip, i) => (123 <span124 key={chip}125 className={cn(126 "t-avatar inline-flex h-8 items-center gap-1 rounded-full pl-2.5 pr-1",127 "bg-[var(--color-paper-2)] text-xs font-medium text-[var(--color-ink)]",128 )}129 onMouseEnter={() => setShifts(i, "in")}130 >131 {chip}132 {!disabled ? (133 <button134 type="button"135 aria-label={`Remove ${chip}`}136 onClick={(e) => {137 e.stopPropagation();138 removeAt(i);139 }}140 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]"141 >142 <X className="size-3.5" strokeWidth={1.75} aria-hidden />143 </button>144 ) : null}145 </span>146 ))}147 <input148 ref={inputRef}149 value={draft}150 disabled={disabled || (max != null && chips.length >= max)}151 placeholder={chips.length === 0 ? placeholder : undefined}152 onChange={(e) => setDraft(e.target.value)}153 onKeyDown={(e) => {154 if (e.key === "Enter" || e.key === ",") {155 e.preventDefault();156 addTokens(splitTokens(draft));157 } else if (e.key === "Backspace" && draft === "" && chips.length) {158 e.preventDefault();159 removeAt(chips.length - 1);160 }161 }}162 onPaste={(e) => {163 const text = e.clipboardData.getData("text");164 if (text.includes(",") || text.includes("\n")) {165 e.preventDefault();166 addTokens(splitTokens(text));167 }168 }}169 onBlur={() => {170 if (draft.trim()) addTokens(splitTokens(draft));171 }}172 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)]"173 />174 </div>175 );176}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { ChipInput } from "@/components/ui/chip-input";2 3<ChipInput value={tags} onChange={setTags} placeholder="Add label…" max={8} />
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| value | string[] | No | — | Controlled chip list. |
| onChange | (value: string[]) => void | No | — | Fires when chips are added or removed. |
| placeholder | string | No | — | Shown when the list is empty. |
| max | number | No | — | Optional cap on chip count. |
| disabled | boolean | No | — | Disables add/remove. |
Best Practices
- Deduping is case-insensitive — keep labels short and consistent.
- Cap with
maxfor ticket labels so the field doesn’t wrap endlessly. - Prefer Enter/comma for commit; don’t rely only on blur.