TagInput

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
  • React
  • TypeScript
  • Tailwind

TagInput

입력 박스 안에 칩이 쌓이는 태그 입력기입니다. Enter·콤마 추가, 빈 칸 Backspace로 마지막 삭제, 중복 방지·최대 개수·추천을 지원합니다.

추천 + 중복 방지 + 최대 개수

입력에 맞춰 추천 칩이 필터되고(↑/↓), 같은 태그는 차단, maxTags 도달 시 입력이 잠깁니다.

Source Code
const [tags, setTags] = useState<string[]>([]);

<TagInput
  tags={tags}
  onChange={setTags}
  suggestions={["Next.js", "Node.js", ...]}
  maxTags={8}
/>
  • 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>
  );
}