MENU
Wwoon

Dialog

Dialog

명령형 API와 컴파운드 컴포넌트로 모달, 알림, 확인 다이얼로그를 구현합니다.

설치

npm install @woon-ui/dialog
import { Dialog, alert, confirm } from '@woon-ui/dialog'
import '@woon-ui/dialog/css'

사용 가이드

Dialog는 기본적으로 useDialog()로 열고 제어합니다. 간단한 알림이나 확인 흐름이 필요할 때는 alert(), confirm() 같은 호출 함수를 사용할 수 있습니다.

import { Dialog, useDialog } from '@woon-ui/dialog'

Note

useDialog(), alert(), confirm()을 화면에 렌더하려면 앱 루트에 DialogRuntime이 필요합니다. 앱 루트 연결은 Runtime Setup, DialogRuntime props는 아래 API 레퍼런스를 참고하세요.

기본 사용법

useDialog() 훅에서 dialog.open()을 호출하면 다이얼로그가 열립니다. render 함수에 close, resolve, update 등의 컨텍스트가 전달됩니다. Dialog 문서를 처음 읽는다면 이 패턴부터 익히는 게 가장 좋습니다.

import { Dialog, useDialog } from '@woon-ui/dialog'

function MyPage() {
  const dialog = useDialog()

  return (
    <button
      onClick={() =>
        dialog.open(({ close }) => (
          <Dialog.Root>
            <Dialog.Overlay />
            <Dialog.Content>
              <Dialog.Title>제목</Dialog.Title>
              <Dialog.Description>설명 텍스트</Dialog.Description>
              <Dialog.Close>닫기</Dialog.Close>
            </Dialog.Content>
          </Dialog.Root>
        ))
      }
    >
      열기
    </button>
  )
}

값 반환

dialog.open()은 핸들 객체를 반환합니다. result Promise로 다이얼로그의 결과를 받을 수 있습니다.

const handle = dialog.open<undefined, string>(({ resolve, close }) => (
  <Dialog.Root>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>선택하세요</Dialog.Title>
      <button onClick={() => resolve('yes')}>확인</button>
      <button onClick={close}>취소</button>
    </Dialog.Content>
  </Dialog.Root>
))

const result = await handle.result
// result.status === 'resolved' → result.value === 'yes'
// result.status === 'dismissed' → 오버레이 클릭이나 ESC로 닫힘

중첩 다이얼로그

다이얼로그 안에서 dialog.open()을 다시 호출하면 스택으로 쌓입니다. ESC 키는 가장 위의 다이얼로그만 닫습니다. closeAll()로 전체를 닫을 수 있습니다.

const dialog = useDialog()

dialog.open(({ close }) => (
  <Dialog.Root>
    <Dialog.Overlay />
    <Dialog.Content>
      <button onClick={() => dialog.open(/* 두 번째 다이얼로그 */)}>
        다음 다이얼로그
      </button>
    </Dialog.Content>
  </Dialog.Root>
))

// 전체 닫기
dialog.closeAll()

API 레퍼런스

useDialog()

useDialog()는 다이얼로그를 열고 제어하는 메서드 객체를 반환합니다.

메서드시그니처설명
open(render, options?) => DialogHandle새 다이얼로그를 열고 핸들을 반환합니다.
close(id: string) => voidid에 해당하는 다이얼로그를 dismissed로 닫습니다.
closeAll() => void열려 있는 모든 다이얼로그를 닫습니다.

open()의 두 번째 인자 options에는 DialogOptions의 모든 필드를 부분 적용할 수 있습니다. initialData?: TData를 함께 넘기면 DialogContext.data의 초기값으로 사용됩니다.

Tip

다이얼로그 내부 어디서든 useWoonDialogContext()를 호출하면 close, resolve, closeAll, options에 접근할 수 있습니다. render prop을 직접 내려보내지 않아도 됩니다.

DialogContext

open()의 render 함수가 인자로 받는 컨텍스트입니다.

type DialogContext<TData = undefined, TResult = void> = {
  data: TData                                               // initialData로 전달한 초기값
  close: () => void                                         // dismissed로 닫기
  resolve: (value: TResult) => void                         // resolved로 닫기
  update: (next: TData | ((prev: TData) => TData)) => void  // data 업데이트
}

data를 사용하지 않는다면 제네릭 없이 DialogContext만 써도 됩니다.

DialogHandle

open()이 반환하는 핸들입니다. 다이얼로그 바깥에서 제어가 필요할 때 사용합니다.

type DialogHandle<TData = undefined, TResult = void> = {
  id: string
  close: () => void
  resolve: (value: TResult) => void
  update: (next: TData | ((prev: TData) => TData)) => void
  result: Promise<DialogResult<TResult>>
}

type DialogResult<TResult> =
  | { status: 'resolved'; value: TResult }
  | { status: 'dismissed'; value: undefined }

result는 닫히는 방식과 무관하게 항상 resolve됩니다. status로 어떻게 닫혔는지 구분하세요.

구조

Dialog는 dialog.open()으로 여는 imperative API와, 내부 콘텐츠를 설명하는 compound parts를 함께 사용합니다. 최소 구조는 아래와 같습니다.

import { Dialog } from '@woon-ui/dialog'

<Dialog.Root>
  <Dialog.Overlay />
  <Dialog.Content>
    <Dialog.Title />
    <Dialog.Description />
    <Dialog.Close />
  </Dialog.Content>
</Dialog.Root>

컴파운드 컴포넌트

아래 part를 조합해 다이얼로그 구조를 만듭니다.

PartDescriptionDefault element
Dialog.Root다이얼로그 전체 구조와 옵션 기본값을 감싸는 루트 컨테이너입니다. `options`와 `onOpenChange`를 지원합니다.
Dialog.Overlay배경 레이어입니다. closeOnOverlayClick이 켜져 있으면 클릭으로 닫힙니다.<div>
Dialog.Content포커스 트랩과 ARIA 속성이 적용되는 실제 다이얼로그 본문입니다.<div role="dialog">
Dialog.Title제목을 렌더링하고 aria-labelledby를 자동으로 연결합니다.<h2>
Dialog.Description본문 설명을 렌더링하고 aria-describedby를 자동으로 연결합니다.<p>
Dialog.Close현재 다이얼로그를 닫는 액션 파트입니다. asChild를 지원합니다.<button>

Dialog.Title, Dialog.Description, Dialog.CloseasChild prop을 지원합니다.

<Dialog.Close asChild>
  <button className="my-close-button">X</button>
</Dialog.Close>

Dialog.Root

Dialog.Root는 공통 modal 동작을 위한 options prop과 실제 열림 상태 lifecycle을 알리는 onOpenChange를 지원합니다.

PropTypeDefault
optionsPartial<DialogOptions>
`overlay`, `trapFocus`, `scrollLock`, `closeOnOverlayClick`, `scrollTarget` 같은 공통 modal 동작을 제어합니다.
onOpenChange(open: boolean) => void
다이얼로그가 실제로 열리고 닫힐 때 호출되는 lifecycle 콜백입니다.

DialogOptions

Tip

런타임 옵션은 Dialog.Root options={{ ... }} 또는 dialog.open(render, options)의 두 번째 인자로 전달합니다.

PropTypeDefault
overlaybooleantrue
배경을 어둡게 덮는 overlay를 표시합니다.
trapFocusbooleantrue
열린 동안 Tab 포커스가 다이얼로그 내부에서만 순환하도록 강제합니다.
scrollLockbooleantrue
열린 동안 배경 문서의 스크롤을 잠급니다.
closeOnOverlayClickbooleantrue
Overlay 클릭 시 다이얼로그를 닫습니다.
scrollTargetstring|Element|nullundefined
스크롤을 잠글 대상 요소. CSS 선택자 또는 Element를 전달합니다. DialogRuntime의 scrollTarget이 앱 전역 기본값으로 사용되며, 이 옵션은 호출별로 오버라이드할 때 사용합니다. 미지정 시 document.body에 적용됩니다.

기본값으로도 일반적인 모달은 충분합니다. 옵션은 동작을 바꿔야 할 때만 건드리면 됩니다.

옵션 적용 우선순위는 dialog.open() 두 번째 인자 → Dialog.Root options 순입니다. 나중에 전달할수록 우선순위가 높습니다.

예를 들어 배경을 막지 않는 패널처럼 쓰고 싶다면 overlay, trapFocus, scrollLock을 꺼서 비모달 패턴으로 전환할 수 있습니다. 다만 이 경우 일반 모달과 다르게 동작한다는 점에 유의하세요.

Tip

레이아웃 밀림 방지 — 다이얼로그가 열릴 때 스크롤바가 사라지면서 레이아웃이 밀릴 수 있습니다. 전역 CSS에 아래 한 줄을 추가하면 스크롤바 공간이 항상 예약되어 밀림 현상이 사라집니다.

html {
  scrollbar-gutter: stable;
}

Note

height: 100dvh 레이아웃body 대신 내부 div가 스크롤을 담당하는 구조라면 scrollLock이 body에 적용되어 효과가 없습니다. DialogRuntimescrollTarget으로 앱 전역 기본값을 지정하세요. 특정 호출에서만 다른 대상이 필요하면 dialog.open()의 두 번째 인자로 오버라이드할 수 있습니다.

// 앱 전역 기본값
<DialogRuntime scrollTarget="#main-scroll" />

// 특정 호출에서만 오버라이드
dialog.open(render, { scrollTarget: '#other' })

DialogRuntime

앱 루트에 한 번 렌더하는 modal runtime입니다.

<DialogRuntime
  zIndex={200}
  scrollTarget="#main"
  components={{
    alert: MyAlert,
    confirm: MyConfirm,
  }}
/>
PropTypeDefault
zIndexnumber200
다이얼로그 z-index 시작값. 중첩될수록 1씩 증가합니다.
scrollTargetstring|Element|nulldocument.body
scrollLock의 기본 잠금 대상. CSS 선택자나 Element를 전달할 수 있고, 미지정 시 document.body에 적용됩니다.
components.alertComponentType<AlertRenderContext>내장 UI
alert() 기본 렌더 컴포넌트를 교체합니다.
components.confirmComponentType<ConfirmRenderContext>내장 UI
confirm() 기본 렌더 컴포넌트를 교체합니다.

커스터마이징

Dialog는 기능적 CSS와 상태 data 속성을 제공하고, 시각 스타일은 사용자 쪽에서 덮어쓰는 방식을 전제로 합니다.

스타일

기본 스타일은 @woon-ui/dialog/css/dialog로 적용됩니다. 컴포넌트 파일 상단 또는 전역 CSS에서 한 번만 import하세요.

import '@woon-ui/dialog/css/dialog'

스타일을 바꾸는 방법은 두 가지로 생각하면 됩니다.

  • 기본 구조는 그대로 두고, 현재 선택자를 기준으로 값을 덮어쓴다
  • 위치와 모션까지 포함해서 완전히 다른 다이얼로그를 만들려면 아래 dialog.css 전체를 출발점으로 복사해 수정한다

이제 dialog.css 하나에 위치, 애니메이션, 시각 스타일이 모두 들어 있습니다. 위치나 모션까지 포함해 바꾸고 싶으면 이 파일 전체를 복사해서 출발점으로 쓰는 편이 가장 안전합니다.

/* ── 스크롤 잠금 ── */
[data-woon-scroll-lock] {
overflow: hidden;
}

/* ── 환경 격리 ── */
[data-woon-dialog-overlay],
[data-woon-dialog-content],
[data-woon-dialog-content] *,
[data-woon-dialog-content] *::before,
[data-woon-dialog-content] *::after {
box-sizing: border-box;
}

/* ── Overlay ── */
[data-woon-dialog-overlay] {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
will-change: opacity;
opacity: 0;
transition: opacity 160ms ease-out;
}

[data-woon-dialog-overlay][data-entered] {
opacity: 1;
}

/* ── Content 위치 / 시각 스타일 ── */
[data-woon-dialog-content] {
position: fixed;
top: 50%;
left: 50%;
width: min(100%, 28rem);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1rem;
line-height: 1.5;
color: inherit;
will-change: transform, opacity;
opacity: 0;
transform: translate(-50%, calc(-50% + 8px)) scale(0.98);
transition:
  opacity 200ms cubic-bezier(0.16, 1, 0.3, 1),
  transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
padding: 1.25rem 1.5rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}

[data-woon-dialog-content][data-entered] {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}

/* ── 퇴장 애니메이션 ── */
[data-woon-dialog-overlay][data-state="closed"] {
animation: woon-overlay-out 120ms ease-in forwards;
transition: none;
}

[data-woon-dialog-content][data-state="closed"] {
animation: woon-dialog-out 140ms ease-in forwards;
transition: none;
}

@keyframes woon-overlay-out {
from { opacity: 1; }
to   { opacity: 0; }
}

@keyframes woon-dialog-out {
from { opacity: 1; transform: translate(-50%, -50%) scale(1); }
to   { opacity: 0; transform: translate(-50%, calc(-50% + 6px)) scale(0.98); }
}

/* ── Title / Description ── */
[data-woon-dialog-title] {
display: block;
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 600;
}

[data-woon-dialog-description] {
display: block;
margin: 0 0 0.7rem;
}

data 속성

속성대상
data-woon-dialog-overlayOverlay
data-woon-dialog-contentContent
data-stateOverlay, Content"open" 또는 "closed"

상태 기반 스타일링 예시:

[data-woon-dialog-content][data-state="open"] {
  animation: my-dialog-in 200ms ease-out;
}

동작과 접근성

기본 Dialog는 모달로 동작합니다. 별도 설정 없이 아래 동작이 자동으로 적용됩니다.

  • 포커스가 다이얼로그 내부에 머뭅니다
  • 닫히면 포커스가 이전 위치로 돌아갑니다
  • 열려 있는 동안 배경 페이지 스크롤이 잠깁니다

키보드 인터랙션

동작
Tab다이얼로그 내부 포커스를 순환합니다
Shift + Tab역방향으로 포커스를 순환합니다
Esc가장 위에 떠 있는 다이얼로그만 닫습니다

ARIA

  • Dialog.Content에는 role="dialog"aria-modal="true"가 적용됩니다
  • Dialog.Title를 사용하면 aria-labelledby가 자동으로 연결됩니다
  • Dialog.Description를 사용하면 aria-describedby가 자동으로 연결됩니다