Dialog
Drawer
dialog 시스템 위에서 동작하는 edge-attached surface. 좌우 패널과 모바일 바텀 드로어를 같은 API로 구성합니다.
설치
npm install @woon-ui/drawerimport { Drawer } from '@woon-ui/drawer'
import '@woon-ui/drawer/css'사용 가이드
Drawer는 새로운 overlay 엔진이 아닙니다. Dialog의 lifecycle, focus trap, scroll lock, ESC 처리, stacking contract를 그대로 사용하면서 surface만 edge-attached panel로 바꾼 컴포넌트입니다.
그래서 기본 사용 흐름도 Dialog와 같습니다. useDialog()로 열고, render 함수 안에서 Drawer.Root를 조합합니다.
Tip
Drawer는 edge-attached surface의 기본 primitive입니다. direction="bottom"이면 모바일 action panel, direction="left" | "right"면 side panel, direction="top"이면 shallow notice panel처럼 같은 overlay contract 위에서 표면만 바꿀 수 있습니다.
기본 사용법
가장 일반적인 패턴은 오른쪽 drawer입니다. direction="right"만 주면, 나머지 모달 동작은 Dialog 기본값을 그대로 따릅니다.
방향
direction으로 surface가 어느 edge에 붙을지 바꿉니다. 좌우 패널, 상단 공지 패널 모두 같은 API를 사용하고, dragToClose도 네 방향 전체에서 같은 방식으로 켤 수 있습니다.
모바일 바텀 Drawer
drag/snap이 필요 없는 모바일 surface라면 direction="bottom"만으로 충분한 경우가 많습니다. dragToClose를 켜면 콘텐츠 전체에서 edge 방향 dismiss gesture를 받을 수 있고, bottom은 맨 위, top은 맨 아래에서만 close drag로 전환됩니다. left/right도 같은 모델을 쓰며, LTR 기준으로 right는 가로 스크롤 시작점, left는 가로 스크롤 끝에서만 close drag를 시작합니다. 입력이나 선택이 우선되어야 하는 영역은 [data-woon-drawer-no-drag]로 opt-out하면 됩니다.
API 레퍼런스
구조
Drawer는 Dialog와 같은 compound 구조를 사용합니다. 최소 구조는 아래와 같습니다.
import { Drawer } from '@woon-ui/drawer'
<Drawer.Root direction="right">
<Drawer.Overlay />
<Drawer.Content>
<Drawer.Title />
<Drawer.Description />
<Drawer.Close />
</Drawer.Content>
</Drawer.Root>컴파운드 컴포넌트
| Part | Description | Default element |
|---|---|---|
| Drawer.Root | direction, options, onOpenChange를 관리하는 루트 컨테이너입니다. | — |
| Drawer.Overlay | Dialog overlay를 그대로 사용하며 backdrop click dismiss도 같은 규칙을 따릅니다. | <div> |
| Drawer.Content | 실제 surface입니다. focus trap, aria 연결, exit lifecycle은 Dialog.Content가 담당합니다. | <div role="dialog"> |
| Drawer.Handle | 선택적인 gesture affordance입니다. 모든 방향에 둘 수 있지만, 보통 `top` / `bottom` mobile sheet에서 가장 유용합니다. | <div> |
| Drawer.Title | 제목을 렌더링하고 aria-labelledby를 연결합니다. | <h2> |
| Drawer.Description | 설명을 렌더링하고 aria-describedby를 연결합니다. | <p> |
| Drawer.Close | 현재 drawer를 닫는 액션 파트입니다. asChild를 지원합니다. | <button> |
Drawer.Title, Drawer.Description, Drawer.Close는 Dialog와 동일하게 asChild를 지원합니다.
Drawer.Root
| Prop | Type | Default |
|---|---|---|
| direction | 'left'|'right'|'top'|'bottom' | 'right' |
| 어느 edge에 붙어 열릴지 지정합니다. | ||
| dragToClose | boolean | false |
| 모든 direction에서 지원합니다. 각 edge 방향으로 콘텐츠를 끌어 drawer를 닫습니다. | ||
| options | Partial<DialogOptions> | — |
| `overlay`, `trapFocus`, `scrollLock`, `closeOnOverlayClick`, `scrollTarget` 같은 공통 modal 동작을 그대로 사용합니다. | ||
| onOpenChange | (open: boolean) => void | — |
| 실제 열림 상태 lifecycle을 알려주는 콜백입니다. | ||
options는 Dialog.Root와 완전히 같은 의미입니다. 동작을 바꿔야 할 때만 전달하면 됩니다.
커스터마이징
스타일
기본 스타일은 @woon-ui/drawer/css로 적용됩니다. Drawer는 동작만 Dialog를 재사용하고, CSS는 완전히 독립적으로 동작합니다. DOM도 data-woon-dialog-* 식별자를 노출하지 않으므로 Dialog와 Drawer를 함께 써도 스타일 경계가 섞이지 않습니다. 기본 CSS는 위치와 전환만 제공하고 surface 크기는 고정하지 않습니다. radius/background/shadow나 방향별 width/max-height 같은 값은 data 속성으로 override하는 방식이 가장 안전합니다.
<Drawer.Root direction="right">
<Drawer.Overlay />
<Drawer.Content style={{ width: '24rem' }} />
</Drawer.Root>완전히 다른 모양이 필요하면 아래 CSS를 복사해 출발점으로 쓰세요.
/* ─── Drawer ──────────────────────────────────────────────────────────────────
Dialog 엔진 위에서 동작하는 edge-attached surface.
overlay lifecycle, focus trap, scroll lock은 Dialog가 맡고,
기본 CSS는 위치와 전환만 정의하고, surface 크기는 강제하지 않습니다.
──────────────────────────────────────────────────────────────────────────── */
/* ── 스크롤 잠금 ── */
[data-woon-scroll-lock] {
overflow: hidden;
}
/* ── Overlay 시각 스타일 ── */
[data-woon-drawer-overlay] {
--woon-drawer-overlay-alpha: 0.52;
position: fixed;
inset: 0;
background-color: rgba(15, 23, 42, var(--woon-drawer-overlay-alpha));
opacity: 0;
will-change: opacity;
transition: opacity 160ms ease-out, background-color 160ms ease-out;
}
[data-woon-drawer-overlay][data-entered] {
opacity: 1;
}
[data-woon-drawer-overlay][data-state="closed"] {
animation: woon-drawer-overlay-out 120ms ease-in forwards;
transition: none;
}
/* ── Content 레이아웃/시각 스타일 ── */
[data-woon-drawer-content],
[data-woon-drawer-content] *,
[data-woon-drawer-content] *::before,
[data-woon-drawer-content] *::after {
box-sizing: border-box;
}
[data-woon-drawer-content] {
position: fixed;
inset: auto;
display: flex;
flex-direction: column;
width: auto;
max-width: 100dvw;
max-height: 100dvh;
font: inherit;
margin: 0;
padding: 1.5rem;
border: 0;
color: inherit;
opacity: 1;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
background: #fff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
will-change: transform;
transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-woon-drawer-content][data-entered] {
opacity: 1;
}
/* ── Edge 위치 ── */
[data-woon-drawer-content][data-direction="right"] {
top: 0;
right: 0;
bottom: 0;
border-radius: 1.5rem 0 0 1.5rem;
transform: translate3d(100%, 0, 0);
}
[data-woon-drawer-content][data-direction="left"] {
top: 0;
left: 0;
bottom: 0;
border-radius: 0 1.5rem 1.5rem 0;
transform: translate3d(-100%, 0, 0);
}
[data-woon-drawer-content][data-direction="bottom"] {
right: 0;
bottom: 0;
left: 0;
border-radius: 1.5rem 1.5rem 0 0;
transform: translate3d(0, 100%, 0);
}
[data-woon-drawer-content][data-direction="top"] {
top: 0;
right: 0;
left: 0;
border-radius: 0 0 1.5rem 1.5rem;
transform: translate3d(0, -100%, 0);
}
/* ── 진입/퇴장 ── */
[data-woon-drawer-content][data-entered][data-direction] {
transform: translate3d(0, 0, 0);
}
[data-woon-drawer-content][data-state="closed"] {
animation: none;
box-shadow: none;
transition: none;
}
[data-woon-drawer-content][data-drag-closing] {
box-shadow: none;
}
[data-woon-drawer-content][data-state="closed"][data-direction="right"]:not([data-drag-closing]) {
animation: woon-drawer-out-right 140ms ease-in forwards;
transition: none;
}
[data-woon-drawer-content][data-state="closed"][data-direction="left"]:not([data-drag-closing]) {
animation: woon-drawer-out-left 140ms ease-in forwards;
transition: none;
}
[data-woon-drawer-content][data-state="closed"][data-direction="bottom"]:not(
[data-drag-closing]
) {
animation: woon-drawer-out-bottom 160ms ease-in forwards;
transition: none;
}
[data-woon-drawer-content][data-state="closed"][data-direction="top"]:not(
[data-drag-closing]
) {
animation: woon-drawer-out-top 160ms ease-in forwards;
transition: none;
}
/* ── Handle ── */
[data-woon-drawer-handle] {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin: -0.5rem 0 0.75rem;
padding: 0.25rem 0 0;
cursor: grab;
touch-action: none;
user-select: none;
}
[data-woon-drawer-handle]::before {
content: "";
display: block;
width: 2.25rem;
height: 0.25rem;
border-radius: 999px;
background: #d4d4d8;
}
[data-woon-drawer-content][data-dragging] {
cursor: grabbing;
}
/* ── Title / Description ── */
[data-woon-drawer-title] {
margin-bottom: 0.35rem;
}
[data-woon-drawer-description] {
margin-bottom: 1rem;
}
@keyframes woon-drawer-overlay-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes woon-drawer-out-right {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(100%, 0, 0);
}
}
@keyframes woon-drawer-out-left {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(-100%, 0, 0);
}
}
@keyframes woon-drawer-out-bottom {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, 100%, 0);
}
}
@keyframes woon-drawer-out-top {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, -100%, 0);
}
}data 속성
[data-woon-drawer-overlay][data-woon-drawer-content][data-woon-drawer-handle][data-woon-drawer-title][data-woon-drawer-description][data-direction="left" | "right" | "top" | "bottom"][data-woon-drawer-no-drag]
동작과 접근성
Drawer는 별도 엔진이 아니라 Dialog surface이기 때문에 다음 동작을 그대로 상속합니다.
- ESC는 가장 위의 overlay만 닫습니다.
trapFocus,scrollLock,closeOnOverlayClick은DialogOptions로 제어합니다.dragToClose는 모든 방향에서 지원합니다.bottom은 내부 scroll chain이 맨 위,top은 맨 아래,right는 가로 스크롤 시작점,left는 가로 스크롤 끝에서만 close drag를 시작합니다. 수평 판정은 현재 LTR 기준입니다.Title/Description은 자동으로aria-labelledby/aria-describedby에 연결됩니다.- overlay stack에 참여하므로
Dialog,Popover,Tooltip과 섞여도 z-index를 수동으로 맞출 필요가 없습니다.