mimizae 님의 블로그
drop-down 공통 컴포넌트 구현 본문
37기 DIVE SOPT에서 진행한 프로젝트 CareNA 프로젝트에서 맡은 부분을 구현한 과정을 담은 글입니다! 🙌🏻

💡 shadcn/ui를 사용한 이유
pnpm dlx shadcn@latest add select
shadcn/ui 컴포넌트을 활용해서 drop-down 공통 컴포넌트를 제작했다.
shadcn/ui 컴포넌트를 사용하기로 한 가장 큰 이유는!! 속도, 일관성, 확장성을 동시에 가져갈 수 있을 거라고 생각했기 때문이다.
shadcn/ui는 Tailwind CSS를 전제로 설계된 컴포넌트 시스템이라 스타일 충돌이나 설정 이슈가 거의 없고, 컴포넌트가 npm 패키지로 감춰져 있는 구조가 아니라 실제 코드가 그대로 프로젝트 안에 생성된다는 점이 특히 매력적이었다!!!
덕분에 디자인 요구사항이나 UX 변화가 생겨도 컴포넌트를 뜯어서 바로 수정할 수 있고, 팀 스타일에 맞게 자유롭게 커스터마이징할 수 있어 유지보수와 확장이 훨씬 수월해진다.
또 하나 중요한 이유는 복잡한 UI 로직을 팀 차원에서 표준화할 수 있다는 점이었다.
특히 Dropdown, Select, Modal 같은 컴포넌트는 접근성, 키보드 인터랙션, 포커스 관리까지 직접 구현하려면 생각보다 많은 로직과 테스트가 필요하다.
이런 부분을 shadcn/ui가 이미 잘 설계된 구조로 제공해 주기 때문에, 팀원 모두가 신뢰할 수 있는 기본 구현!! 위에서 작업할 수 있다.
💡 select.tsx
🔻 select.tsx 코드 전문 🔻
import * as SelectPrimitive from "@radix-ui/react-select";
import type * as React from "react";
import { ChevronSDown } from "../../assets/svg";
import { cn } from "../../libs/cn";
const focusReset = `
outline-none
focus:outline-none
focus-visible:outline-none
focus:ring-0
focus-visible:ring-0
focus-visible:shadow-none
`;
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
`group head03-sb-16 data-[disabled]:head03-sb-16 flex h-[4.7rem] w-full min-w-[33.5rem] cursor-pointer items-center rounded-[12px] border border-gray-500 bg-white px-[2rem] py-[1.2rem] data-[state=open]:border-primary-500`,
focusReset,
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon className="ml-[1.2rem] transition-transform duration-500 ease-in-out group-data-[disabled]:hidden group-data-[state=open]:rotate-180">
<ChevronSDown />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
side="bottom"
sideOffset={4}
className={cn(
`z-50 flex max-h-[18.4rem] w-[var(--radix-select-trigger-width)] flex-col rounded-[8px] bg-white p-[0.8rem] data-[state=open]:animate-[select-slide-down_200ms_ease-out]`,
className,
)}
{...props}
>
<SelectPrimitive.Viewport>{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
`body03-r-16 flex w-full cursor-pointer items-center gap-[1rem] rounded-[8px] bg-white px-[0.8rem] py-[1rem] transition-colors hover:bg-gray-100 active:bg-gray-100`,
focusReset,
className,
)}
{...props}
>
{children}
</SelectPrimitive.Item>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
};
이 Select 컴포넌트는 위의 pnpm dlx shadcn@latest add select 명령어로 생성된 shadcn/ui의 기본 Select를 기반으로 한다!!
shadcn/ui의 Select는 본질적으로 이렇게 구성된다.
- Radix UI Select → 접근성, 포커스 관리, 키보드 제어, open/close 상태
- shadcn/ui 래퍼 → Tailwind 기반 스타일 시스템
- 각 Radix primitive를 얇게 감싼 컴포넌트 모음
즉, 실제 로직은 Radix가 담당하고, shadcn은 이걸 Tailwind로 쓰기 좋게 감싸 놓은 UI 레이어에 가깝다.
이번 프로젝트에서는 이 기본 생성물에서 우리 서비스에 쓰지 않는 서브 컴포넌트는 전부 제거하고, 남은 Trigger, Content, Item 같은 핵심 파트만 디자인 명세에 맞게 직접 수정했다.
shadcn/ui + Radix 조합의 핵심은 Radix가 노출해 주는 data attribute를 Tailwind에서 바로 쓸 수 있다는 점이다.
예를 들어 아래를 보면
<SelectPrimitive.Trigger
className={cn(
`
group
flex w-[33.5rem] h-[4.7rem]
px-[2rem] py-[1.2rem]
items-center
rounded-[1.2rem]
head03-sb-16 border border-gray-500
bg-white cursor-pointer
data-[state=open]:border-primary-500
data-[disabled]:head03-sb-16
`,
focusReset,
className,
)}
>
Radix Select는 내부 상태에 따라 자동으로 이런 attribute를 붙인다.
data-state="open"
data-disabled
그리고 Tailwind는 이를 그대로 선택자로 쓸 수 있다!!
data-[state=open]:border-primary-500
data-[disabled]:head03-sb-16
이 덕분에 JS 없이도 열렸을 때 테두리 강조, 비활성화 상태 스타일 같은 UI 규칙을 완전히 선언적으로 표현할 수 있다!!!!
또 group + group-data-[state=open] 패턴을 사용해
<SelectPrimitive.Icon
className="
transition-transform duration-450
group-data-[state=open]:rotate-180
group-data-[disabled]:hidden
"
>
트리거 상태에 따라 화살표 아이콘 회전과 숨김 처리까지 CSS 레벨에서 깔끔하게 제어할 수 있었다.
그런데… ☠️☠️☠️ 문제 발생…
하고 싶었던 트랜지션이 있었는데, select의 content의 opacity + translateY에 transition을 걸어서 열릴 때는 fade in, 닫힐 때는 fade out 되도록 하고 싶었다…
그런데 전혀 작동하지 않았다.
이건 구현 실수나 Tailwind 한계가 아니라, Radix Select의 구조 때문에 원천적으로 불가능한 방식이었다.
문제를 추적하며 Radix Select의 동작 방식을 살펴보았고, Radix SelectContent는 state=closed가 되는 순간 DOM에서 즉시 unmount된다는 사실을 알게 되었다.
즉, 상태 변화의 흐름이 이렇다. open → closed → 바로 DOM 제거
그래서 다음과 같이 data-[state=closed]에 transition 기반 스타일을 적용하더라도 애니메이션이 실행될 시간 자체가 존재하지 않았다.
(transition은 DOM이 유지된 상태에서만 동작하기 때문에, unmount가 즉시 발생하는 구조에서는 적용 자체가 불가능했다.)
data-[state=closed]:opacity-0
data-[state=closed]:-translate-y-2
그래서!! keyframes + animate 방식으로 해결했다.
Radix 공식 문서를 확인한 결과, Select와 같은 컴포넌트에서는 transition 대신 keyframes 기반의 animate-in / animate-out 방식을 사용하는 것이 권장된다는 점을 알게 되었다.
data-[state=open]:animate-xxx
data-[state=closed]:animate-yyy
이 방식은 unmount 전에 애니메이션 한 프레임을 보장해 주기 때문에 닫힘 애니메이션을 구현할 수 있었다!!!
@layer utilities {
@keyframes select-slide-down {
from {
opacity:0;
transform:translateY(-8px);
}
to {
opacity:1;
transform:translateY(0);
}
}
@keyframes select-slide-up {
from {
opacity:1;
transform:translateY(0);
}
to {
opacity:0;
transform:translateY(-8px);
}
}
}
애니메이션을 재사용 가능한 스타일 토큰으로 관리하기 위해 global.css 에 keyframes를 정의했다.
그리고 SelectContent에 이렇게 적용했다.
<SelectPrimitive.Content
className={cn(
`
flex flex-col
w-[var(--radix-select-trigger-width)]
h-[18.4rem]
p-[0.8rem]
rounded-[0.8rem]
bg-white
z-50
data-[state=open]:animate-[select-slide-down_200ms_ease-out]
data-[state=closed]:animate-[select-slide-up_200ms_ease-in]
`,
className,
)}
>
이 구조 덕분에 Radix의 생명주기와 충돌하지 않으면서도 디자인 명세에 맞는 부드러운 드롭다운 애니메이션을 구현할 수 있었다!!!
🤩🤩 휴…!!
💡 drop-down.tsx
이 컴포넌트의 역할은 UI 전용 공통 컴포넌트이다.
상위 컴포넌트가 이미 정렬 및 포맷까지 끝낸 옵션을 이 형태로 넘겨주면, DropDown은 그걸 그대로 UI에만 렌더링한다.
interface DropDownOption {
value:string; // ex) "2025-11-02"
label:string; // ex) "2025년 11월 02일"
subLabel:string; // ex) 병원명
}
이렇게 분리한 이유는!!
- 날짜 포맷 (2025-11-02 → 2025년 11월 02일)
- 날짜 최신순 정렬
날짜 포맷이나 최신순 정렬 같은 로직이 UI 컴포넌트가 감당해야 할 책임이 아니라고 생각했기 때문이다.
이런 로직들은 데이터의 의미와 타입에 강하게 의존하는 로직이다.
즉, 이런 것들은 전부 도메인 로직이라서, 공통 컴포넌트가 알 필요가 없다고 생각했다.
공통 컴포넌트는 어떻게 보여줄지만 책임지고, 무엇을 보여줄지는 상위 컴포넌트가 결정하게 만드는 게 자연스럽다고 생각했다!!!
그리고
<SelectPrimitive.Trigger className="flex items-center ...">
{children}
<SelectPrimitive.Icon className="ml-[1.2rem] ..." />
</SelectPrimitive.Trigger>
SelectTrigger 내부는 이렇게 구성되어 있어서 SelectTrigger가 감싸는 두 label들, span들은 하나의 children으로 여겨진다.
<SelectTrigger disabled={options.length <= 1 }>
<span>{selectedOption?.label}</span>
<span className="flex-1 text-left ml-[2rem] body04-r-14">
{selectedOption?.subLabel}
</span>
</SelectTrigger>
그래서 Trigger는 children과 Icon 사이만 레이아웃을 책임지기에 여기에 gap을 주면 라벨 묶음과 화살표 아이콘 사이에만 적용된다 ㅜ.ㅜ
하지만... 실제 디자인 요구사항은, 날짜와 병원명 사이 간격 병원명과 아이콘 사이 간격이 각각 서로 달라 트리거 내부의 두 라벨은 Trigger가 아니라 DropDown 쪽에서 직접 간격을 제어하도록 했다!!
<span>{option.label}</span>
<span className="ml-[1rem] body05-r-12">{option.subLabel}</span>
💡 최종 구현 예시
- item이 한 개일 때

- 어떤 값을 선택한 후, 드롭다운을 열었을 때 -> 선택한 값을 리스트에 없음

- 호버 or 터치 했을 때

💡ETC
'FE' 카테고리의 다른 글
| 인증/인가 전략 (0) | 2026.03.01 |
|---|---|
| 프론트엔드의 보안 (0) | 2026.03.01 |
| button 공통 컴포넌트 구현 (0) | 2026.02.16 |
| 마이페이지 min-height 문제 (0) | 2026.02.10 |
| ticker 구현기 (0) | 2026.02.09 |