Select
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
TypeScript
TypeScript
JavaScript
Python
Rust (disabled)
Select
기본 HTML <select> 대신, Input과 동일한 크기/스타일을 가진 div 기반 Select 컴포넌트입니다.
최대 노출 옵션 개수, 위/아래 방향 자동 결정, 비활성 옵션, 에러/헬퍼 텍스트 등을 지원합니다.
기본 Select
placeholder와 옵션을 가진 가장 기본적인 Select 예시
Source Code
언어 선택
아직 선택되지 않았습니다.
Live Preview
사이즈 변경
Input 높이와 동일한 sm / md / lg 사이즈를 제공합니다.
Small
Small
Live Preview
Medium
Medium
Live Preview
Large
Large
Live Preview
최대 표시 개수 + 스크롤
maxVisibleOptions로 노출되는 옵션 개수를 제한하고, 초과분은 스크롤로 표시합니다.
Source Code
언어 (최대 4개 표시)
Live Preview
상태 선택 (에러 / 헬퍼 텍스트)
에러 메시지와 헬퍼 텍스트를 사용하는 예시
Source Code
상태
프로젝트의 현재 상태를 선택하세요.
Live Preview
옵션을 팝업으로 사용 (usePopup) — 두 가지 방식
usePopup={true}일 때 popupPlacement로 표시 방식을 구분할 수 있습니다. 'anchor'는 트리거 버튼 옆에 드롭다운처럼 붙어서 열리고, 'center'는 화면 중앙에 모달처럼 열립니다.
Source Code
앵커형 (anchor) — 트리거 옆에 붙는 형태
언어 선택 (앵커형)
옵션이 버튼 아래에 붙어서 열립니다.
중앙형 (center) — 화면 중앙 모달 형태
언어 선택 (중앙형)
옵션이 화면 중앙에 팝업으로 열립니다.
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
Selecttypescript
'use client';
/**
* Select 컴포넌트
*
* - 기본 HTML <select> 대신 div 기반으로 구현한 커스텀 셀렉트 박스입니다.
* - 기존 Input 컴포넌트와 동일한 높이/스타일을 사용합니다.
* - maxVisible 옵션으로 노출되는 옵션 개수를 제한하고, 초과분은 스크롤로 표시합니다.
* - 기본적으로 아래로 펼쳐지며, 화면 아래 여유 공간이 부족하면 자동으로 위로 펼쳐집니다.
* - ESC / 바깥 클릭 시 닫기, 키보드 방향키/Enter 지원 등 실사용에 적합한 동작을 제공합니다.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type HTMLAttributes,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { ChangeEvent } from 'react';
import { cn } from '@/lib/cn';
import { Modal } from '@/components/Modal';
export interface SelectOption {
value: string;
label: string;
description?: string;
disabled?: boolean;
}
export type SelectSize = 'sm' | 'md' | 'lg';
export interface SelectProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
/** 선택된 값 (제어 컴포넌트) */
value?: string;
/** 값 변경 시 호출 */
onChange?: (value: string | undefined) => void;
/** 옵션 리스트 */
options: SelectOption[];
/** placeholder 텍스트 (선택값이 없을 때 표시) */
placeholder?: string;
/** 비활성화 여부 */
disabled?: boolean;
/** 한 번에 보이는 최대 옵션 개수 (초과 시 스크롤) */
maxVisibleOptions?: number;
/** 셀렉트 높이 (Input과 동일한 높이 사용) */
size?: SelectSize;
/** 에러 메시지 (Input과 동일한 스타일) */
error?: string;
/** 보조 설명 텍스트 */
helperText?: string;
/** 라벨 텍스트 */
label?: string;
/**
* "직접 입력"과 같은 커스텀 입력 옵션의 value
* - 이 value를 가진 옵션이 선택되면 아래에 input 필드가 노출됩니다.
*/
customInputOptionValue?: string;
/** 커스텀 입력 필드 placeholder */
customInputPlaceholder?: string;
/** 커스텀 입력 값 변경 시 호출 */
onCustomInputChange?: (value: string) => void;
/** true면 옵션 목록을 모달(Modal)로 띄워서 선택 (기본 false: 인라인 드롭다운) */
usePopup?: boolean;
/**
* usePopup일 때 팝업 표시 방식.
* - 'anchor': 선택박스(트리거) 아래에 모달형 패널로 표시
* - 'center': 화면 중앙에 Modal로 표시
*/
popupPlacement?: 'anchor' | 'center';
/** usePopup일 때 모달 상단에 표시할 제목 (선택) */
popupTitle?: string;
}
const sizeClassMap: Record<SelectSize, string> = {
sm: 'h-9 text-sm px-3',
md: 'h-10 text-sm px-3.5',
lg: 'h-11 text-base px-4',
};
// "직접 입력" 오버레이 input의 폰트 크기를 셀렉트와 동일하게 맞추기 위한 맵
const inputTextSizeClassMap: Record<SelectSize, string> = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
// Live Preview(SelectHero) 목업과 동일한 chevron-down 아이콘
function ChevronDown({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 9l-7 7-7-7" />
</svg>
);
}
// 인라인 드롭다운 / 앵커 팝업 / 중앙 모달 — 세 모드가 공유하는 옵션 행 스타일
function optionRowClass(state: {
disabled?: boolean;
selected: boolean;
highlighted: boolean;
}): string {
return cn(
'flex w-full min-w-0 items-start rounded-lg px-3 py-2 text-left text-sm transition-colors',
state.disabled
? 'cursor-not-allowed text-slate-400'
: state.selected
? 'cursor-pointer bg-violet-50 font-semibold text-violet-700'
: state.highlighted
? 'cursor-pointer bg-slate-100 text-slate-900'
: 'cursor-pointer text-slate-700 hover:bg-slate-50',
);
}
// 옵션 라벨 + 설명(있을 때) — 세 모드 공통 렌더
function OptionContent({
option,
selected,
}: {
option: SelectOption;
selected: boolean;
}) {
return (
<div className="flex min-w-0 flex-col items-start">
<span className="truncate">{option.label}</span>
{option.description && (
<span
className={cn(
'mt-0.5 text-xs',
selected ? 'text-violet-600/70' : 'text-slate-500',
)}
>
{option.description}
</span>
)}
</div>
);
}
export function Select({
value,
onChange,
options,
placeholder = '옵션을 선택하세요',
disabled,
maxVisibleOptions = 5,
size = 'md',
error,
helperText,
label,
className,
customInputOptionValue,
customInputPlaceholder,
onCustomInputChange,
usePopup = false,
popupPlacement = 'anchor',
popupTitle,
...rest
}: SelectProps) {
const [open, setOpen] = useState(false);
const [internalValue, setInternalValue] = useState<string | undefined>(value);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const [dropdownDirection, setDropdownDirection] = useState<'down' | 'up'>('down');
const [customInput, setCustomInput] = useState('');
const [focused, setFocused] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const anchorPanelRef = useRef<HTMLDivElement | null>(null);
const customInputRef = useRef<HTMLInputElement | null>(null);
const [anchorRect, setAnchorRect] = useState<{ top: number; left: number; width: number } | null>(null);
const typedRef = useRef('');
const typeAheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastKeyRef = useRef<string | null>(null);
// 제어/비제어 동기화
useEffect(() => {
setInternalValue(value);
}, [value]);
const selectedOption = useMemo(
() => options.find((opt) => opt.value === internalValue),
[options, internalValue],
);
const visibleOptions = options;
const isCustomSelected =
!!customInputOptionValue && internalValue === customInputOptionValue;
const handleOpen = useCallback(() => {
if (disabled) return;
setOpen(true);
if (visibleOptions.length === 0) {
setHighlightedIndex(-1);
return;
}
const idx =
selectedOption != null
? visibleOptions.findIndex((o) => o.value === selectedOption.value)
: 0;
setHighlightedIndex(idx >= 0 ? idx : 0);
}, [disabled, selectedOption, visibleOptions]);
const handleClose = useCallback(() => {
setOpen(false);
setHighlightedIndex(-1);
}, []);
const handleToggle = () => {
if (open) {
handleClose();
} else {
handleOpen();
}
};
const handleSelect = useCallback(
(option: SelectOption) => {
if (option.disabled) return;
setInternalValue(option.value);
onChange?.(option.value);
handleClose();
},
[onChange],
);
// Tab 키 감지: Tab으로 포커스가 올 경우 다음 onFocus에서 드롭다운 열기
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
lastKeyRef.current = 'Tab';
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
// 바깥 클릭 감지
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current?.contains(target)) return;
if (usePopup && popupPlacement === 'anchor' && anchorPanelRef.current?.contains(target)) return;
handleClose();
};
// 캡처 단계(true)로 듣는다 — Modal 패널의 onMouseDown stopPropagation(버블)보다 먼저 발동해
// 모달 안에서도 바깥 클릭 시 정상적으로 닫힌다.
document.addEventListener('mousedown', handleClickOutside, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
};
}, [open, handleClose, usePopup, popupPlacement]);
// ESC, 방향키, Enter, 타입어헤드(스펠링 키)
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) => {
const enabledOptions = visibleOptions.filter((o) => !o.disabled);
if (enabledOptions.length === 0) return -1;
if (prev < 0) return visibleOptions.indexOf(enabledOptions[0]);
const current = visibleOptions[prev];
const currentIdxInEnabled = enabledOptions.indexOf(current);
const nextEnabled =
enabledOptions[(currentIdxInEnabled + 1) % enabledOptions.length];
return visibleOptions.indexOf(nextEnabled);
});
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) => {
const enabledOptions = visibleOptions.filter((o) => !o.disabled);
if (enabledOptions.length === 0) return -1;
if (prev < 0) return visibleOptions.indexOf(
enabledOptions[enabledOptions.length - 1],
);
const current = visibleOptions[prev];
const currentIdxInEnabled = enabledOptions.indexOf(current);
const nextIndex =
currentIdxInEnabled <= 0
? enabledOptions.length - 1
: currentIdxInEnabled - 1;
const nextEnabled = enabledOptions[nextIndex];
return visibleOptions.indexOf(nextEnabled);
});
return;
}
if (e.key === 'Enter' && highlightedIndex >= 0) {
e.preventDefault();
const option = visibleOptions[highlightedIndex];
if (!option.disabled) handleSelect(option);
return;
}
// 타입어헤드: 한 글자(영문/한글) 입력 시 해당 스펠링으로 시작하는 옵션으로 포커스
const isLetter =
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
(/\p{L}/u.test(e.key) || /[a-zA-Z]/.test(e.key));
if (isLetter) {
e.preventDefault();
if (typeAheadTimerRef.current) {
clearTimeout(typeAheadTimerRef.current);
typeAheadTimerRef.current = null;
}
typedRef.current += e.key;
typeAheadTimerRef.current = setTimeout(() => {
typedRef.current = '';
typeAheadTimerRef.current = null;
}, 500);
const query = typedRef.current.toLowerCase();
const enabledOptions = visibleOptions.filter((o) => !o.disabled);
const found = enabledOptions.findIndex((o) =>
o.label.toLowerCase().startsWith(query),
);
if (found >= 0) {
const index = visibleOptions.indexOf(enabledOptions[found]);
setHighlightedIndex(index);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (typeAheadTimerRef.current) {
clearTimeout(typeAheadTimerRef.current);
typeAheadTimerRef.current = null;
}
};
}, [open, visibleOptions, highlightedIndex, handleClose, handleSelect]);
// 포커스된 옵션을 뷰 안으로 스크롤
useEffect(() => {
if (!open || highlightedIndex < 0 || !listRef.current) return;
const el = listRef.current.querySelector(
`[data-option-index="${highlightedIndex}"]`,
);
if (el) {
(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
}, [open, highlightedIndex]);
// 열릴 때 아래/위 방향 결정
useEffect(() => {
if (!open || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight;
const approxItemHeight = 40; // px (옵션 한 줄 높이 추정)
const estimatedListHeight =
Math.min(visibleOptions.length, maxVisibleOptions) * approxItemHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < estimatedListHeight && spaceAbove > spaceBelow) {
setDropdownDirection('up');
} else {
setDropdownDirection('down');
}
}, [open, visibleOptions.length, maxVisibleOptions]);
const dropdownMaxHeight = maxVisibleOptions * 40; // 옵션 1줄 높이 기준
// 앵커 패널 위치 (usePopup + anchor일 때)
useEffect(() => {
if (!open || !usePopup || popupPlacement !== 'anchor' || !buttonRef.current) {
setAnchorRect(null);
return;
}
const rect = buttonRef.current.getBoundingClientRect();
setAnchorRect({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 280),
});
const onScrollOrResize = () => {
if (buttonRef.current) {
const r = buttonRef.current.getBoundingClientRect();
setAnchorRect({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 280) });
}
};
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [open, usePopup, popupPlacement]);
const handleCustomInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setCustomInput(next);
onCustomInputChange?.(next);
};
// "직접 입력" 옵션이 선택되면 입력창에 자동 포커스
useEffect(() => {
if (isCustomSelected && customInputRef.current) {
customInputRef.current.focus();
}
}, [isCustomSelected]);
return (
<div className={cn('space-y-1.5', className)} ref={rootRef}>
{label && (
<div className="block text-xs font-semibold text-slate-700">
{label}
</div>
)}
<div className="relative">
{/* 선택 영역 (Input과 같은 스타일의 div) */}
<button
type="button"
ref={buttonRef}
disabled={disabled}
onClick={handleToggle}
onFocus={() => {
setFocused(true);
if (lastKeyRef.current === 'Tab') {
lastKeyRef.current = null;
handleOpen();
}
}}
onBlur={() => setFocused(false)}
className={cn(
'selectedItemButton group flex w-full items-center justify-between rounded-xl border bg-white text-slate-900 shadow-sm transition-colors',
'hover:cursor-pointer',
'focus:outline-none',
'disabled:opacity-60 disabled:cursor-not-allowed disabled:bg-slate-50',
error ? 'border-rose-400' : 'border-slate-300 hover:border-slate-400',
sizeClassMap[size],
focused && (error
? 'ring-2 ring-rose-500/20 border-rose-500'
: 'ring-2 ring-violet-500/20 border-violet-500'),
)}
>
<span
className={cn(
'truncate text-left flex-1',
!selectedOption && 'text-slate-400',
)}
>
{!isCustomSelected && (selectedOption ? selectedOption.label : placeholder)}
</span>
{/* 화살표 — Live Preview 목업과 동일한 chevron-down */}
<span
className={cn(
'ml-2 flex flex-shrink-0 items-center text-slate-400 transition-transform duration-200',
open && 'rotate-180 text-slate-600',
)}
>
<ChevronDown className="size-3.5" />
</span>
</button>
{/* "직접 입력" 모드에서 셀렉트 상단에 동일 사이즈 입력창 오버레이 */
}
{isCustomSelected && !disabled && (
<div className="pointer-events-none absolute inset-0 flex items-stretch">
{/* 실제 입력 영역: 오른쪽 화살표 버튼 영역을 비워두기 위해 너비를 줄여서 렌더 */}
<input
ref={customInputRef}
type="text"
value={customInput}
onChange={handleCustomInputChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={customInputPlaceholder || '직접 입력'}
className={cn(
'pointer-events-auto h-full w-[calc(100%-2.5rem)] rounded-lg border border-transparent bg-transparent pl-4 text-black outline-none',
inputTextSizeClassMap[size],
)}
/>
{/* 오른쪽 화살표 영역은 비워두어 아래 버튼의 화살표가 그대로 보이고 클릭도 가능하게 함 */}
<div className="pointer-events-none h-full w-10" />
</div>
)}
{/* 드롭다운 리스트 (usePopup이 false일 때만 인라인) */}
{!usePopup && open && (
<div
ref={listRef}
className={cn(
'absolute left-0 right-0 z-50 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl shadow-slate-900/10',
'animate-in fade-in zoom-in-95 duration-150',
dropdownDirection === 'up' ? 'bottom-full mb-1 mt-0' : 'top-full mt-1',
)}
style={{ maxHeight: dropdownMaxHeight, overflowY: 'auto' }}
>
{visibleOptions.length === 0 ? (
<div className="px-3 py-2.5 text-sm text-slate-500">
선택할 수 있는 옵션이 없습니다.
</div>
) : (
<ul className="space-y-0.5">
{visibleOptions.map((option, index) => {
const isSelected = option.value === internalValue;
const isHighlighted = index === highlightedIndex;
return (
<li key={option.value}>
<button
type="button"
data-option-index={index}
data-selected={isSelected ? 'true' : 'false'}
disabled={option.disabled}
onClick={() => handleSelect(option)}
className={cn(
'selectedItemOptionButton',
optionRowClass({
disabled: option.disabled,
selected: isSelected,
highlighted: isHighlighted,
}),
)}
onMouseEnter={() => {
if (!option.disabled) setHighlightedIndex(index);
}}
>
<OptionContent option={option} selected={isSelected} />
</button>
</li>
);
})}
</ul>
)}
</div>
)}
{/* 앵커형 모달 패널 (usePopup + anchor: 선택박스 아래 모달 형태) */}
{usePopup && popupPlacement === 'anchor' && open && anchorRect && typeof window !== 'undefined' && createPortal(
<div
className="fixed inset-0 z-[100]"
role="dialog"
aria-modal="true"
aria-label={popupTitle || '선택'}
>
<div
className="absolute inset-0 bg-black/10"
aria-hidden
onMouseDown={handleClose}
/>
<div
ref={anchorPanelRef}
className="relative z-10 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 min-w-[280px] max-w-[90vw]"
style={{
position: 'fixed',
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
maxHeight: dropdownMaxHeight + (popupTitle ? 52 : 0),
}}
onMouseDown={(e) => e.stopPropagation()}
>
{popupTitle && (
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/80 px-4 py-2.5">
<h3 className="text-sm font-semibold text-slate-900">{popupTitle}</h3>
<button
type="button"
onClick={handleClose}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-700"
aria-label="닫기"
>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18" /><path d="M6 6L18 18" />
</svg>
</button>
</div>
)}
<ul
className="w-full space-y-0.5 overflow-y-auto p-2"
style={{ maxHeight: dropdownMaxHeight }}
>
{visibleOptions.length === 0 ? (
<li className="w-full list-none px-3 py-2.5 text-sm text-slate-500">
선택할 수 있는 옵션이 없습니다.
</li>
) : (
visibleOptions.map((option, index) => {
const isSelected = option.value === internalValue;
const isHighlighted = index === highlightedIndex;
return (
<li key={option.value} className="w-full list-none">
<button
type="button"
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setHighlightedIndex(index);
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(option);
}}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
className={optionRowClass({
disabled: option.disabled,
selected: isSelected,
highlighted: isHighlighted,
})}
>
<OptionContent option={option} selected={isSelected} />
</button>
</li>
);
})
)}
</ul>
</div>
</div>,
document.body,
)}
{/* 화면 중앙 Modal (usePopup + center) */}
{usePopup && popupPlacement === 'center' && (
<Modal
open={open}
onClose={handleClose}
title={popupTitle}
size="sm"
showCloseIcon
closeOnBackdrop
closeOnEsc
bodyClass="p-0"
>
<ul
className="w-full space-y-0.5 overflow-y-auto p-2"
style={{ maxHeight: dropdownMaxHeight }}
>
{visibleOptions.length === 0 ? (
<li className="w-full px-3 py-2.5 text-sm text-slate-500">
선택할 수 있는 옵션이 없습니다.
</li>
) : (
visibleOptions.map((option, index) => {
const isSelected = option.value === internalValue;
const isHighlighted = index === highlightedIndex;
return (
<li key={option.value} className="w-full list-none">
<button
type="button"
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setHighlightedIndex(index);
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(option);
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className={optionRowClass({
disabled: option.disabled,
selected: isSelected,
highlighted: isHighlighted,
})}
>
<OptionContent option={option} selected={isSelected} />
</button>
</li>
);
})
)}
</ul>
</Modal>
)}
</div>
{error && <p className="text-xs font-medium text-rose-600">{error}</p>}
{helperText && !error && (
<p className="text-xs text-slate-500">{helperText}</p>
)}
</div>
);
}