

게시글 정렬을 위한 옵션을 선택할 수 있는 귀여운 select box를 만들고 싶었습니다.
<select className="p-2 bg-amber-300">
{options.map((option, index) => (
<option className="py-2 bg-cyan-200" key={index} value={option.value}>
{option.name}
</option>
))}
</select>

option 태그의 스타일링은 매우 제한적이고, 브라우저와 OS 환경에 영향을 많이 받습니다.
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option
따라서 React와 TailwindCSS를 활용하여 재사용 가능한 select box 컴포넌트를 만들기로 했습니다.
import PropTypes from 'prop-types';
import { useState } from 'react';
const options = [
{ name: '조회수 많은 순', value: 'mostViewed' },
{ name: '댓글 많은 순', value: 'mostCommented' },
{ name: '최근 활동 순', value: 'recent' },
{ name: '초기화', value: null },
];
const BoardSort = ({ selectedOption, setSelectedOption }) => {
const [isOpen, setIsOpen] = useState(false);
const handleOptionSelect = (option) => {
setSelectedOption(option);
setIsOpen(false);
};
return (
<div className="relative text-center w-32">
<button
className={`bg-white py-1 w-full border-1 border-black cursor-pointer transition-all ${isOpen ? 'rounded-t-2xl' : 'rounded-2xl'}`}
onClick={() => setIsOpen((prev) => !prev)}
>
{selectedOption.value ? selectedOption.name : '게시글 정렬'}
</button>
<ul
className={`w-full text-center absolute left-0 top-full transition-opacity ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
{options.map((option, index) => (
<li
key={index}
className={`py-1 cursor-pointer border-1 border-black bg-white hover:bg-primary-100 ${index === options.length - 1 ? 'rounded-b-2xl' : ''}`}
onClick={() => handleOptionSelect(option)}
>
{option.name}
</li>
))}
</ul>
</div>
);
};
BoardSort.propTypes = {
selectedOption: PropTypes.string.isRequired,
setSelectedOption: PropTypes.func.isRequired,
};
export default BoardSort;
옵션을 선택하면 setSelectedOption으로 부모 컴포넌트에 선택된 옵션이 전달되고, setIsOpen으로 select box가 닫히게 구현했습니다.
TailwindCSS로 간단한 스타일과 애니메이션을 추가했습니다.

const selectBoxRef = useRef(null);
return (
<div className="relative text-center w-32" ref={selectBoxRef}>
{/* ... */}
</div>
);
useRef는 반환된 객체의 current 프로퍼티에 리렌더링 사이에도 값이 유지되는 정보를 저장할 때 사용됩니다.
selectBoxRef.current에 박스의 최상위 DOM 노드를 저장한 후에,
useEffect(() => {
const handleOutsideClick = (event) => {
if (
selectBoxRef.current &&
!selectBoxRef.current.contains(event.target)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);
useEffect를 사용해 컴포넌트의 첫 렌더링(마운트) 시점에 document에 mousedown event를 등록하고, 컴포넌트가 DOM에서 제거되는 언마운트 시점에 등록한 event를 제거합니다.
handleOutsideClick event는 클릭된 노드가 select box의 하위 노드인지 contains를 이용해 확인하고, 아니라면 isOpen을 false로 변경합니다. (박스 외부가 클릭된다면 박스를 닫습니다.)

handleOutsideClick을 구현하고 나니, 다른 페이지에서도 동일한 기능을 사용할 수 있다는 것을 발견했습니다.
| 구절 공유해요 페이지 더보기 버튼 | 스터디 상세 홈 페이지 float nav button |
|---|---|
![]() |
![]() |
각 드롭다운의 UI가 달라 공통 컴포넌트 사용은 못 하지만, 모두 드롭다운 박스 외부를 클릭했을 때 박스가 닫혀야하기 때문에, 이 기능을 커스텀 훅으로 제작하기로 했습니다.
useOutsideClick.jsx
import { useEffect } from 'react';
const useOutsideClick = (targetRef, handler) => {
useEffect(() => {
// targetRef가 클릭된 노드를 포함하지 않으면 handler를 실행하는 함수
const handleOutsideClick = (event) => {
if (targetRef.current && !targetRef.current.contains(event.target)) {
handler();
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);
};
export default useOutsideClick;
사용 예시
const SmallDropdownBox = ({ className }) => {
const dropdownBoxRef = useRef();
const [isOpen, setIsOpen] = useState(false);
const handleDropdownClick = () => {
setIsOpen((prev) => !prev);
};
// dropdownBox 외부클릭 시 setIsOpen(false) 실행
useOutsideClick(dropdownBoxRef, () => setIsOpen(false));
return (
<div ref={dropdownBoxRef} className={`relative ${className}`}>
<img
src={moreIcon}
className="cursor-pointer"
onClick={handleDropdownClick}
/>
<div
className={`absolute border-slate-400 border-1 rounded-xl bg-white top-0 right-[24px] opacity-0 transition-all ${isOpen ? 'opacity-100' : 'pointer-events-none'}`}
>
<p
className="py-1 w-14 text-center border-b-1 border-slate-400 cursor-pointer hover:bg-primary-100 rounded-t-xl"
onClick={() => setIsOpen(false)}
>
수정
</p>
<p
className="py-1 w-14 text-center cursor-pointer hover:bg-primary-100 rounded-b-xl"
onClick={() => setIsOpen(false)}
>
삭제
</p>
</div>
</div>
);
};
위 코드에서 드롭다운 박스 영역은 img 태그 다음의 div지만, 여기에서 ref 값을 얻으면 img를 클릭했을 때도 박스 외부로 인식이 됩니다.
그럼 useOutsideClick과 handleDropdownClick이 중복 실행되어 드롭다운 박스가 깜빡이는 버그가 발생합니다.

따라서 img와 박스를 감싼 div에 dropdownBoxRef를 주었습니다.
| 구절 공유해요 페이지 더보기 버튼 | 스터디 상세 홈 페이지 float nav button |
|---|---|
![]() |
![]() |