Overview
Typing plus chips — not a reorder list. New chips enter at scale(0.96) via @starting-style; exits reverse the same opacity and scale over --duration-quick (150ms). 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, useEffect, useRef, useState } from "react";4import { X } from "lucide-react";5import { cn } from "@/lib/utils";6 7function readChipMs() {8 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;9 const name = reduce ? "--duration-micro" : "--duration-quick";10 const v = parseFloat(11 getComputedStyle(document.documentElement).getPropertyValue(name),12 );13 return Number.isFinite(v) ? v : reduce ? 80 : 150;14}15 16export type ChipInputProps = {17 value?: string[];18 defaultValue?: string[];19 onChange?: (value: string[]) => void;20 placeholder?: string;21 max?: number;22 disabled?: boolean;23 className?: string;24};25 26function splitTokens(raw: string): string[] {27 return raw28 .split(/[,|\n]+/)29 .map((s) => s.trim())30 .filter(Boolean);31}32 33export function ChipInput({34 value: valueProp,35 defaultValue = [],36 onChange,37 placeholder = "Add tag…",38 max,39 disabled,40 className,41}: ChipInputProps) {42 const isControlled = valueProp !== undefined;43 const [uncontrolled, setUncontrolled] = useState(defaultValue);44 const chips = isControlled ? valueProp : uncontrolled;45 const [draft, setDraft] = useState("");46 const [exiting, setExiting] = useState<string[]>([]);47 const inputRef = useRef<HTMLInputElement>(null);48 const groupRef = useRef<HTMLDivElement>(null);49 const chipsRef = useRef(chips);50 const exitTimers = useRef<Map<string, number>>(new Map());51 chipsRef.current = chips;52 53 useEffect(54 () => () => {55 exitTimers.current.forEach((id) => window.clearTimeout(id));56 },57 [],58 );59 60 const setChips = useCallback(61 (next: string[]) => {62 if (!isControlled) setUncontrolled(next);63 onChange?.(next);64 },65 [isControlled, onChange],66 );67 68 const setShifts = useCallback(69 (activeIdx: number | null, phase: "in" | "out") => {70 if (!groupRef.current) return;71 const cs = getComputedStyle(document.documentElement);72 const num = (name: string, fb: number) => {73 const v = parseFloat(cs.getPropertyValue(name));74 return Number.isFinite(v) ? v : fb;75 };76 const ease = (name: string, fb: string) =>77 cs.getPropertyValue(name).trim() || fb;78 79 const lift = num("--avatar-lift", -4);80 const falloff = num("--avatar-falloff", 0.45);81 const scale = num("--avatar-scale", 1.05);82 const tf =83 phase === "out"84 ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")85 : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");86 87 groupRef.current88 .querySelectorAll<HTMLElement>(".t-avatar")89 .forEach((el, i) => {90 el.style.transitionTimingFunction = tf;91 if (activeIdx == null) {92 el.style.setProperty("--shift", "0px");93 el.style.setProperty("--scale-active", "1");94 return;95 }96 const d = Math.abs(i - activeIdx);97 el.style.setProperty(98 "--shift",99 (lift * Math.pow(falloff, d)).toFixed(3) + "px",100 );101 el.style.setProperty(102 "--scale-active",103 i === activeIdx ? String(scale) : "1",104 );105 });106 },107 [],108 );109 110 const addTokens = (tokens: string[]) => {111 if (disabled || tokens.length === 0) return;112 const seen = new Set(chips.map((c) => c.toLowerCase()));113 const next = [...chips];114 for (const t of tokens) {115 if (max != null && next.length >= max) break;116 if (seen.has(t.toLowerCase())) continue;117 seen.add(t.toLowerCase());118 next.push(t);119 }120 if (next.length !== chips.length) setChips(next);121 setDraft("");122 };123 124 const removeAt = (index: number) => {125 if (disabled) return;126 const chip = chips[index];127 if (!chip || exiting.includes(chip)) return;128 129 const commit = () => {130 setExiting((e) => e.filter((c) => c !== chip));131 setChips(chipsRef.current.filter((c) => c !== chip));132 };133 134 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {135 commit();136 return;137 }138 139 setExiting((e) => (e.includes(chip) ? e : [...e, chip]));140 const prev = exitTimers.current.get(chip);141 if (prev) window.clearTimeout(prev);142 exitTimers.current.set(143 chip,144 window.setTimeout(() => {145 exitTimers.current.delete(chip);146 commit();147 }, readChipMs()),148 );149 };150 151 return (152 <div153 ref={groupRef}154 className={cn(155 "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",156 "bg-[var(--color-paper)] ring-1 ring-[var(--color-rule)]",157 "focus-within:ring-2 focus-within:ring-[var(--color-focus)]",158 disabled && "cursor-not-allowed opacity-50",159 className,160 )}161 onClick={() => inputRef.current?.focus()}162 onMouseLeave={() => setShifts(null, "out")}163 >164 {chips.map((chip, i) => (165 <span166 key={chip}167 className={cn("t-chip", exiting.includes(chip) && "is-exit")}168 >169 <span170 className={cn(171 "t-avatar inline-flex h-8 items-center gap-1 rounded-full pl-2.5 pr-1",172 "bg-[var(--color-paper-2)] text-xs font-medium text-[var(--color-ink)]",173 )}174 onMouseEnter={() => setShifts(i, "in")}175 >176 {chip}177 {!disabled ? (178 <button179 type="button"180 aria-label={`Remove ${chip}`}181 onClick={(e) => {182 e.stopPropagation();183 removeAt(i);184 }}185 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]"186 >187 <X className="size-3.5" strokeWidth={1.75} aria-hidden />188 </button>189 ) : null}190 </span>191 </span>192 ))}193 <input194 ref={inputRef}195 value={draft}196 disabled={disabled || (max != null && chips.length >= max)}197 placeholder={chips.length === 0 ? placeholder : undefined}198 onChange={(e) => setDraft(e.target.value)}199 onKeyDown={(e) => {200 if (e.key === "Enter" || e.key === ",") {201 e.preventDefault();202 addTokens(splitTokens(draft));203 } else if (e.key === "Backspace" && draft === "" && chips.length) {204 e.preventDefault();205 const idx = chips.findLastIndex((c) => !exiting.includes(c));206 if (idx >= 0) removeAt(idx);207 }208 }}209 onPaste={(e) => {210 const text = e.clipboardData.getData("text");211 if (text.includes(",") || text.includes("\n")) {212 e.preventDefault();213 addTokens(splitTokens(text));214 }215 }}216 onBlur={() => {217 if (draft.trim()) addTokens(splitTokens(draft));218 }}219 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)]"220 />221 </div>222 );223}
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.