TagInput
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
- React
- TypeScript
- Tailwind
TagInput
입력 박스 안에 칩이 쌓이는 태그 입력기입니다. Enter·콤마 추가, 빈 칸 Backspace로 마지막 삭제, 중복 방지·최대 개수·추천을 지원합니다.
추천 + 중복 방지 + 최대 개수
입력에 맞춰 추천 칩이 필터되고(↑/↓), 같은 태그는 차단, maxTags 도달 시 입력이 잠깁니다.
Source Code
- React
- TypeScript
["React", "TypeScript"]Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
TagInputtypescript
'use client';
/**
* TagInput 컴포넌트
*
* 입력 박스 안에 칩이 인라인으로 쌓이는 태그 입력기.
* - Enter·콤마로 추가, 빈 칸에서 Backspace로 마지막 칩 삭제, ×로 개별 제거.
* - 빈 칸에서 ←/→ 로 칩 사이를 이동해 Backspace/Delete로 그 칩 삭제.
* - 한글 IME 조합 중 Enter는 흘려보냄(isComposing) — 조합 확정과 충돌 방지.
* - suggestions로 입력에 맞춰 필터되는 추천 칩(↑/↓ 이동), 중복 방지·최대 개수 옵션.
*/
import { useId, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
export interface TagInputProps {
tags: string[];
onChange: (next: string[]) => void;
suggestions?: string[];
showSuggestions?: boolean;
preventDuplicates?: boolean;
maxTags?: number;
placeholder?: string;
label?: string;
disabled?: boolean;
className?: string;
}
export function TagInput({
tags,
onChange,
suggestions = [],
showSuggestions = true,
preventDuplicates = true,
maxTags = Infinity,
placeholder,
label,
disabled = false,
className,
}: TagInputProps) {
const [draft, setDraft] = useState('');
const [chipFocus, setChipFocus] = useState(-1);
const [activeSuggest, setActiveSuggest] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useId();
const atLimit = tags.length >= maxTags;
const exists = (value: string) => tags.some((t) => t.toLowerCase() === value.toLowerCase());
const filtered = useMemo(() => {
if (!showSuggestions) return [];
const q = draft.trim().toLowerCase();
return suggestions.filter((s) => !exists(s) && (q === '' ? true : s.toLowerCase().includes(q)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, suggestions, tags, showSuggestions]);
const addTag = (raw: string) => {
const value = raw.trim();
if (!value || atLimit) {
setDraft('');
return;
}
if (preventDuplicates && exists(value)) {
setDraft('');
setActiveSuggest(-1);
return;
}
onChange([...tags, value]);
setDraft('');
setActiveSuggest(-1);
};
const removeAt = (index: number) => onChange(tags.filter((_, i) => i !== index));
const focusInput = () => {
setChipFocus(-1);
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (chipFocus === -1 && filtered.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveSuggest((i) => (i + 1) % filtered.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveSuggest((i) => (i <= 0 ? filtered.length - 1 : i - 1));
return;
}
}
if (e.key === 'Enter' || e.key === ',') {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
e.preventDefault();
if (activeSuggest >= 0 && filtered[activeSuggest]) addTag(filtered[activeSuggest]);
else addTag(draft);
return;
}
if (e.key === 'Backspace' && draft === '') {
e.preventDefault();
if (tags.length > 0) removeAt(tags.length - 1);
return;
}
if (e.key === 'ArrowLeft' && draft === '' && tags.length > 0) {
e.preventDefault();
setChipFocus((i) => (i === -1 ? tags.length - 1 : Math.max(0, i - 1)));
return;
}
if (e.key === 'ArrowRight' && chipFocus !== -1) {
e.preventDefault();
setChipFocus((i) => {
const next = i + 1;
if (next >= tags.length) {
focusInput();
return -1;
}
return next;
});
return;
}
if (chipFocus !== -1 && (e.key === 'Backspace' || e.key === 'Delete')) {
e.preventDefault();
removeAt(chipFocus);
setChipFocus(-1);
inputRef.current?.focus();
}
};
return (
<div className={cn('space-y-2', className)}>
{label && (
<label htmlFor={inputId} className="block text-xs font-semibold text-slate-700">
{label}
</label>
)}
<div
onClick={focusInput}
className={cn(
'flex min-h-[3rem] cursor-text flex-wrap items-center gap-1.5 rounded-2xl border bg-white px-2 py-2 transition-colors',
'border-slate-300 focus-within:border-violet-400 focus-within:ring-2 focus-within:ring-violet-100',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<ul className="contents" role="list" aria-label="추가된 태그">
{tags.map((tag, i) => {
const focused = chipFocus === i;
return (
<li key={`${tag}-${i}`}>
<span
className={cn(
'inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-sm font-semibold transition-colors',
focused ? 'bg-violet-600 text-white' : 'bg-violet-50 text-violet-700',
)}
>
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeAt(i);
focusInput();
}}
aria-label={`${tag} 태그 삭제`}
className={cn(
'ml-0.5 flex h-4 w-4 items-center justify-center rounded-full transition-colors',
focused
? 'text-white/80 hover:bg-white/20 hover:text-white'
: 'text-violet-400 hover:bg-violet-200/60 hover:text-violet-700',
)}
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} aria-hidden>
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</span>
</li>
);
})}
</ul>
<input
ref={inputRef}
id={inputId}
type="text"
value={draft}
disabled={disabled || atLimit}
autoComplete="off"
placeholder={
atLimit
? '최대 개수 도달'
: placeholder ?? (tags.length === 0 ? '입력 후 Enter 또는 콤마' : '추가…')
}
onChange={(e) => {
setDraft(e.target.value);
setChipFocus(-1);
setActiveSuggest(-1);
}}
onFocus={() => setChipFocus(-1)}
onKeyDown={handleKeyDown}
className="min-w-[7rem] flex-1 bg-transparent px-1.5 py-1 text-sm text-slate-800 outline-none placeholder:text-slate-400 disabled:cursor-not-allowed"
/>
</div>
{showSuggestions && filtered.length > 0 && !atLimit && !disabled && (
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
{filtered.map((s, i) => (
<button
key={s}
type="button"
onClick={() => {
addTag(s);
inputRef.current?.focus();
}}
onMouseEnter={() => setActiveSuggest(i)}
className={cn(
'rounded-full border px-2.5 py-1 text-xs font-semibold transition-colors',
activeSuggest === i
? 'border-violet-300 bg-violet-50 text-violet-700'
: 'border-slate-200 bg-white text-slate-500 hover:border-violet-200 hover:text-violet-600',
)}
>
+ {s}
</button>
))}
</div>
)}
</div>
);
}