MENU
Wwoon

Dialog

Drawer

dialog 시스템 위에서 동작하는 edge-attached surface. 좌우 패널과 모바일 바텀 드로어를 같은 API로 구성합니다.

설치

npm install @woon-ui/drawer
import { 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 기본값을 그대로 따릅니다.

import { Drawer } from '@woon-ui/drawer'
import { useDialog } from '@woon-ui/dialog'

function ProjectDrawer() {
  return (
    <Drawer.Root direction="right">
      <Drawer.Overlay />
      <Drawer.Content>
        <Drawer.Title>프로젝트 세부 정보</Drawer.Title>
        <Drawer.Description>오른쪽 가장자리에서 열리는 drawer입니다.</Drawer.Description>
        <Drawer.Close>닫기</Drawer.Close>
      </Drawer.Content>
    </Drawer.Root>
  )
}

function MyPage() {
  const dialog = useDialog()

  return (
    <button onClick={() => dialog.open(() => <ProjectDrawer />)}>
      열기
    </button>
  )
}

방향

direction으로 surface가 어느 edge에 붙을지 바꿉니다. 좌우 패널, 상단 공지 패널 모두 같은 API를 사용하고, dragToClose도 네 방향 전체에서 같은 방식으로 켤 수 있습니다.

import { Drawer } from '@woon-ui/drawer'

<Drawer.Root direction="left" dragToClose>
  <Drawer.Overlay />
  <Drawer.Content style={{ width: 'min(24rem, 100dvw)' }}>
    <Drawer.Title>왼쪽 탐색 패널</Drawer.Title>
    <Drawer.Description>콘텐츠를 왼쪽 edge로 끌어 닫을 수 있습니다.</Drawer.Description>
  </Drawer.Content>
</Drawer.Root>

모바일 바텀 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하면 됩니다.

import { Drawer } from '@woon-ui/drawer'

<Drawer.Root direction="bottom" dragToClose>
  <Drawer.Overlay />
  <Drawer.Content style={{ maxHeight: 'min(75dvh, 28rem)' }}>
    <Drawer.Handle />
    <Drawer.Title>모바일 액션 패널</Drawer.Title>
    <Drawer.Description>
      콘텐츠를 아래로 끌어 닫을 수 있습니다.
    </Drawer.Description>
    <div data-woon-drawer-no-drag>
      <textarea />
    </div>
  </Drawer.Content>
</Drawer.Root>

API 레퍼런스

구조

DrawerDialog와 같은 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>

컴파운드 컴포넌트

PartDescriptionDefault element
Drawer.Rootdirection, options, onOpenChange를 관리하는 루트 컨테이너입니다.
Drawer.OverlayDialog 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.CloseDialog와 동일하게 asChild를 지원합니다.

Drawer.Root

PropTypeDefault
direction'left'|'right'|'top'|'bottom''right'
어느 edge에 붙어 열릴지 지정합니다.
dragToClosebooleanfalse
모든 direction에서 지원합니다. 각 edge 방향으로 콘텐츠를 끌어 drawer를 닫습니다.
optionsPartial<DialogOptions>
`overlay`, `trapFocus`, `scrollLock`, `closeOnOverlayClick`, `scrollTarget` 같은 공통 modal 동작을 그대로 사용합니다.
onOpenChange(open: boolean) => void
실제 열림 상태 lifecycle을 알려주는 콜백입니다.

optionsDialog.Root와 완전히 같은 의미입니다. 동작을 바꿔야 할 때만 전달하면 됩니다.

커스터마이징

스타일

기본 스타일은 @woon-ui/drawer/css로 적용됩니다. Drawer는 동작만 Dialog를 재사용하고, CSS는 완전히 독립적으로 동작합니다. DOM도 data-woon-dialog-* 식별자를 노출하지 않으므로 DialogDrawer를 함께 써도 스타일 경계가 섞이지 않습니다. 기본 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, closeOnOverlayClickDialogOptions로 제어합니다.
  • dragToClose는 모든 방향에서 지원합니다. bottom은 내부 scroll chain이 맨 위, top은 맨 아래, right는 가로 스크롤 시작점, left는 가로 스크롤 끝에서만 close drag를 시작합니다. 수평 판정은 현재 LTR 기준입니다.
  • Title / Description은 자동으로 aria-labelledby / aria-describedby에 연결됩니다.
  • overlay stack에 참여하므로 Dialog, Popover, Tooltip과 섞여도 z-index를 수동으로 맞출 필요가 없습니다.