Dialog | CoUI
LogoCoUI

Dialog

다이얼로그/모달 컴포넌트

Dialog#

사용자에게 확인, 입력, 정보를 요청하는 모달 다이얼로그 컴포넌트입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 사용자에게 중요한 확인을 요청할 때 (삭제, 저장 등 되돌리기 어려운 작업)
  • 추가 정보를 입력받아야 할 때 (폼 다이얼로그)
  • 중요한 알림이나 경고를 표시할 때

대신 다른 컴포넌트를 사용하세요:

  • Toast: 간단한 알림이나 성공/실패 피드백을 표시할 때
  • Drawer: 복잡한 내용이나 긴 폼을 사이드에서 보여줄 때
  • Popover: 특정 요소에 부착된 간단한 정보를 표시할 때
  • Tooltip: 짧은 도움말 텍스트만 필요할 때

기본 사용법 (Basic Usage)#

// 확인 다이얼로그
Dialog(
  open: isDeleteDialogOpen,
  title: Text('삭제 확인'),
  content: Text('이 항목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.'),
  actions: [
    Button(
      variant: CoreButtonVariant.outline,
      onPressed: handleCancel,
      child: Text('취소'),
    ),
    Button(
      variant: CoreButtonVariant.destructive,
      onPressed: handleDelete,
      child: Text('삭제'),
    ),
  ],
  onClose: handleCancel,
)

// 커스텀 다이얼로그
Dialog(
  open: isEditDialogOpen,
  title: Text('프로필 편집'),
  content: Column(
    children: [
      TextField(label: '이름', onChanged: handleNameChange),
      TextField(label: '이메일', onChanged: handleEmailChange),
    ],
  ),
  actions: [
    Button(
      variant: CoreButtonVariant.ghost,
      onPressed: handleCancel,
      child: Text('취소'),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSave,
      child: Text('저장'),
    ),
  ],
  onClose: handleCancel,
)
// 확인 다이얼로그 (open 속성으로 상태 제어)
Dialog(
  open: isDeleteDialogOpen,
  title: Text('삭제 확인'),
  content: Text('이 항목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.'),
  actions: [
    Button(
      variant: CoreButtonVariant.outline,
      onPressed: handleCancel,
      child: Text('취소'),
    ),
    Button(
      variant: CoreButtonVariant.destructive,
      onPressed: handleDelete,
      child: Text('삭제'),
    ),
  ],
  onClose: handleCancel,
)

// 커스텀 콘텐츠 다이얼로그 (프로필 편집 폼)
Dialog(
  open: isEditDialogOpen,
  title: Text('프로필 편집'),
  content: div([
    TextField(label: '이름', onChanged: handleNameChange),
    TextField(label: '이메일', onChanged: handleEmailChange),
  ]),
  actions: [
    Button(
      variant: CoreButtonVariant.ghost,
      onPressed: handleCancel,
      child: Text('취소'),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSave,
      child: Text('저장'),
    ),
  ],
  onClose: handleCancel,
)

// 알림 다이얼로그 (단순 확인 버튼만)
Dialog(
  open: isAlertOpen,
  title: Text('오류'),
  content: Text('네트워크 연결에 실패했습니다.'),
  actions: [
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleClose,
      child: Text('확인'),
    ),
  ],
  onClose: handleClose,
)

Props / Parameters#

속성타입기본값설명
openbooltrue다이얼로그 표시 여부
title Widget? null 다이얼로그 제목 위젯
contentWidget?null내용 위젯
leading Widget? null 제목 앞 위젯 (아이콘 등)
trailing Widget? null 제목 뒤 위젯 (닫기 버튼 등)
actions List<Widget>? null 하단 액션 버튼
onClose VoidCallback? null 닫기 콜백
dialogStyle CoreDialogStyle? null panel chrome / nested 슬롯 묶음

빠른 오버라이드 (Chain)#

이미 만든 Dialog 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class DialogChainExample extends StatefulWidget {
  const DialogChainExample({super.key});

  @override
  State<DialogChainExample> createState() => _DialogChainExampleState();
}

class _DialogChainExampleState extends State<DialogChainExample> {
  bool _open = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Button(
          variant: CoreButtonVariant.primary,
          onPressed: () => setState(() => _open = true),
          child: const Text('Open Dialog'),
        ),
        Dialog(
          open: _open,
          onClose: () => setState(() => _open = false),
          title: const Text('Are you sure?'),
          content: const Text('This action cannot be undone.'),
          actions: [
            Button(
              variant: CoreButtonVariant.outline,
              onPressed: () => setState(() => _open = false),
              child: const Text('Cancel'),
            ),
            Button(
              variant: CoreButtonVariant.primary,
              onPressed: () => setState(() => _open = false),
              child: const Text('Continue'),
            ),
          ],
        ).withStyle(
          const CoreDialogStyle(
            panelBackgroundColor: CoreColor.token(
              CoreColors.surfaceContainerHigh,
            ),
            panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            panelPadding: CoreEdgeInsets.all(CoreSpace.space24),
            sectionGapStyle: CoreGapStyle(size: CoreSpace.space12),
            actionSpacing: CoreSpace.space12,
          ),
        ),
      ],
    );
  }
}
class DialogChainExample extends StatefulComponent {
  const DialogChainExample({super.key});

  @override
  State<DialogChainExample> createState() => _DialogChainExampleState();
}

class _DialogChainExampleState extends State<DialogChainExample> {
  bool _open = false;

  @override
  Component build(BuildContext context) {
    return div([
      Button(
        variant: CoreButtonVariant.primary,
        onPressed: () => setState(() => _open = true),
        child: Text('Open Dialog'),
      ),
      Dialog(
        open: _open,
        onClose: () => setState(() => _open = false),
        title: Text('Are you sure?'),
        content: Text('This action cannot be undone.'),
        actions: [
          Button(
            variant: CoreButtonVariant.outline,
            onPressed: () => setState(() => _open = false),
            child: Text('Cancel'),
          ),
          Button(
            variant: CoreButtonVariant.primary,
            onPressed: () => setState(() => _open = false),
            child: Text('Continue'),
          ),
        ],
      ).withStyle(
        const CoreDialogStyle(
          panelBackgroundColor: CoreColor.token(
            CoreColors.surfaceContainerHigh,
          ),
          panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius24),
          panelPadding: CoreEdgeInsets.all(CoreSpace.space24),
          sectionGapStyle: CoreGapStyle(size: CoreSpace.space12),
          actionSpacing: CoreSpace.space12,
        ),
      ),
    ]);
  }
}

스타일 시스템 — dialogStyle#

Dialog 의 panel chrome / 슬롯 미세 조정은 단일 dialogStyle (CoreDialogStyle) 으로 흐릅니다. 동작 / 콘텐츠 슬롯은 위젯 파라미터 그대로.

Dialog(
  open: isOpen,
  title: Text('확인'),
  content: Text('정말 삭제하시겠습니까?'),
  actions: [
    Button(variant: .outline, onPressed: cancel, child: Text('취소')),
    Button(variant: .destructive, onPressed: confirm, child: Text('삭제')),
  ],
  onClose: handleClose,
  dialogStyle: CoreDialogStyle(
    panelBackgroundColor: CoreColor.token(CoreColors.surface),
    panelBorderRadius: CoreBorderRadius.all(CoreRadius.radius16),
    panelPadding: CoreEdgeInsets.all(CoreSpace.space24),
    surfaceBlur: 0,
    surfaceOpacity: 1,
    barrierColor: CoreColor.token(CoreColors.scrim),
    barrierOpacity: CoreOpacity.opacity40,
    titleStyle: CoreTextStyle.token(CoreTextStyles.titleLarge),
    contentStyle: CoreTextStyle.token(
      CoreTextStyles.bodyMedium,
      color: CoreColor.token(CoreColors.onSurfaceVariant),
    ),
    // actionButtonStyle 은 actions 안의 Button 이 자동 적용
    // (위 actions 처럼 variant 직접 주입)
  ),
)

CoreDialogStyle 필드#

필드타입설명
panelBackgroundColor CoreColor? Panel background fill colour override. When null, defers to [defaultsByVariant] background ( surface token).
panelBorderColor CoreColor? Panel border stroke colour override. When null, defers to [defaultPanelBorderColor] ( outline token).
panelBorderWidth double? Panel border width override (logical pixels). When null, defers to [defaultPanelBorderWidth] (1 logical px).
panelBorderRadius CoreBorderRadius? Panel border radius override. When null, defers to [defaultPanelBorderRadius] (16 logical px uniform).
panelPadding CoreEdgeInsets? Panel content padding override. When null, defers to [defaultPanelPadding] (24 logical px on all sides).
panelMaxWidth double? Panel maximum width override (logical pixels). When null, defers to [defaultPanelMaxWidth] (512).
surfaceBlur double? Backdrop blur sigma applied behind the panel.
surfaceOpacity double? Panel surface opacity multiplier (0.0 – 1.0).
panelBoxShadow List<CoreShadowLayer>? Elevation shadow of the dialog panel. When null, defers to [defaultPanelBoxShadow] ( CoreShadow.xl , the Modal/Dialog rung).
shadowBaseColor CoreColor? Base colour the [panelBoxShadow] layers tint against. When null, defers to [defaultShadowBaseColor] ( shadow token).
barrierColor CoreColor? Backdrop barrier overlay colour override. When null, defers to [defaultsByVariant] overlayColor ( onSurface token).
barrierOpacity double? Backdrop (scrim) opacity multiplier override (0.0 – 1.0). When null, defers to [defaultBarrierOpacity] (0.8).
sectionGapStyle CoreGapStyle? Gap slot between dialog stack sections — header / content / actions — and inside the header row between leading / title / trailing. Forwarded straight to Gap(gapStyle: …) (Flutter) and to the inter-slot inline gap CSS (Web). null defers to [defaultSectionGapStyle] (16 logical px).
actionSpacing double? Gap between adjacent action buttons inside the actions row (logical pixels). Rendered as Row(spacing: …) (Flutter native) / inline gap rem CSS on the footer row (Web). null defers to [defaultActionSpacing] (8 logical px).
transitionDuration Duration? Open/close transition duration override. When null, defers to [defaultTransitionDuration] (200 ms).
scaleAnimationStart double? Panel open-animation start scale override. When null, defers to [defaultScaleAnimationStart] (0.9).
scaleAnimationEnd double? Panel open-animation end scale override. When null, defers to [defaultScaleAnimationEnd] (1.0).
titleStyle CoreTextStyle? Title text style override.
contentStyle CoreTextStyle? Content (body) text style override.
actionButtonStyle CoreButtonStyle? Action button style override. To change an action button's variant , inject the button widget directly via Dialog(actions: [Button(...)]) rather than packing variant into this style — 원칙 8 (asChild pattern). No default, and must not have one. Null means the actions the caller handed in are rendered as they were handed in: Web branches on when resolved.actionButtonStyle != null and otherwise emits action untouched, taking no copy. A constant would rebuild every action Button in every dialog with dialog-level chrome merged underneath its own. And the value it would have to carry does not exist as a constant. A Button 's baseline chrome is per-variant ( CoreButtonStyle.defaultsByVariant ), and anything named here outranks that table inside Button 's resolver — so one constant would repaint a .ghost Cancel and a filled Save the same, which is the exact opposite of what the two buttons are for.

asChild 패턴 — action 버튼 변경#

action 버튼의 시맨틱 (variant) 변경이 필요할 때는 actions 슬롯에 직접 위젯을 주입합니다:

Dialog(
  actions: [
    Button(variant: .outline, child: Text('취소')),       // ← variant 직접
    Button(variant: .destructive, child: Text('삭제')),
  ],
)

dialogStyle.actionButtonStyle 은 chrome 미세 조정용 (paddingH / labelStyle 등).

Resolve chain#

CoreDialogStyle.defaultX / defaultsVariant (static const, 단일 출처)
  → CoreDialogTheme.style                       // 프로젝트 공통
  → widget.dialogStyle                          // 인스턴스별

변형 (Variants)#

알림 다이얼로그#

Dialog(
  open: isAlertOpen,
  title: Text('오류'),
  content: Text('네트워크 연결에 실패했습니다.'),
  actions: [
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleClose,
      child: Text('확인'),
    ),
  ],
  onClose: handleClose,
)

아이콘 포함#

Dialog(
  open: isOpen,
  leading: Icon(LucideIcons.triangleAlert),
  title: Text('경고'),
  content: Text('이 작업은 취소할 수 없습니다.'),
  onClose: handleClose,
)

동작 스펙 (Behavior)#

열기/닫기#

  • 양 플랫폼 모두 open boolean 으로 표시 여부를 제어하고, 닫힘은 onClose 콜백으로 통보합니다
  • Flutter: open 이 true 가 되면 내부적으로 dialog route 를 push, false 가 되면 pop
  • Web: open 이 true 일 때만 OverlayHost.dialog 레이어에 패널을 마운트

스크림 (배경 오버레이)#

  • 다이얼로그가 열리면 뒤쪽에 반투명 배경이 표시되어 배경 콘텐츠와 분리
  • 스크림 색·불투명도는 dialogStyle.barrierColor / barrierOpacity 로 조정
  • 스크림 클릭 / Escape 로 닫기는 onClose 가 있을 때만 활성화됩니다onClose 를 주지 않으면 dismiss 경로가 없는 다이얼로그가 됩니다

포커스 관리#

  • Flutter: dialog route 가 FocusScope 로 감싸져 배경 위젯이 traversal 에서 빠집니다
  • Web: 패널은 role="dialog" + aria-modal="true" 를 emit 하지만 JS 포커스 트랩은 없습니다 — 배경 요소로 Tab 이 넘어갈 수 있고, 닫힌 뒤 이전 포커스로의 복원도 없습니다

애니메이션#

  • 양 플랫폼 모두 dialogStyle.transitionDurationscaleAnimationStart / scaleAnimationEnd 를 씁니다
  • Flutter: ScaleTransition + FadeTransition
  • Web: CSS 트랜지션

Overlay 마운트#

  • Flutter: 루트 Overlay 에 마운트되어 ancestor ClipRect / Transform 영향 없이 항상 최상위에 표시
  • Web: OverlayHost.dialog 레이어로 portal — position: fixed 기반이라 ancestor overflow: hidden / transform 컨테이너 안에 Dialog 를 배치해도 잘리거나 좌표 어긋남이 없음. 같은 페이지에 popover / menu / tooltip 이 열려있어도 z-순서상 dialog 가 그 위에 위치

사용 가이드라인 (Usage Guidelines)#

✅ Do#

위험한 작업은 결과를 명확히 설명하세요.

Dialog(
  open: isOpen,
  title: Text('계정 삭제'),
  content: Text('계정을 삭제하면 모든 데이터가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.'),
  actions: [
    Button(variant: CoreButtonVariant.outline, onPressed: handleCancel, child: Text('취소')),
    Button(variant: CoreButtonVariant.destructive, onPressed: handleDeleteAccount, child: Text('계정 삭제')),
  ],
  onClose: handleCancel,
)

사용자가 결과를 충분히 이해한 뒤 결정할 수 있습니다.


❌ Don't#

모호한 메시지로 확인을 요청하지 마세요.

Dialog(
  open: isOpen,
  title: Text('확인'),
  content: Text('진행하시겠습니까?'),
  actions: [
    Button(variant: CoreButtonVariant.primary, onPressed: handleDeleteAccount, child: Text('확인')),
  ],
  onClose: handleCancel,
)

무엇이 진행되는지 알 수 없어 실수로 위험한 작업을 실행할 수 있습니다.

✅ Do#

다이얼로그는 간결하게 유지하세요.

Dialog(
  open: isOpen,
  title: Text('변경사항 저장'),
  content: Text('저장하지 않은 변경사항이 있습니다. 저장하시겠습니까?'),
  actions: [
    Button(variant: CoreButtonVariant.outline, onPressed: handleDiscard, child: Text('저장하지 않음')),
    Button(variant: CoreButtonVariant.primary, onPressed: handleSave, child: Text('저장')),
  ],
  onClose: handleDiscard,
)

하나의 결정에 집중하면 사용자가 빠르게 판단할 수 있습니다.


❌ Don't#

다이얼로그에 과도한 내용을 넣지 마세요.

Dialog(
  open: isOpen,
  title: Text('설정'),
  content: ComplexSettingsForm(), // 긴 폼, 여러 탭
  onClose: handleClose,
)

복잡한 내용은 별도 페이지나 Drawer를 사용하세요. 다이얼로그는 간단한 확인/입력에 적합합니다.

✅ Do#

닫을 수 있는 방법을 항상 제공하세요.

Dialog(
  open: isOpen,
  title: Text('알림'),
  content: Text('작업이 완료되었습니다.'),
  actions: [
    Button(variant: CoreButtonVariant.primary, onPressed: handleClose, child: Text('확인')),
  ],
  onClose: handleClose,
)

ESC 키, 스크림 클릭, 닫기 버튼 중 하나 이상의 닫기 방법이 있어야 합니다.


❌ Don't#

닫기 방법 없이 다이얼로그를 표시하지 마세요.

Dialog(
  open: isOpen,
  title: Text('알림'),
  content: Text('작업이 완료되었습니다.'),
  // actions 없음, onClose 없음 — 닫을 수 없음
)

사용자가 다이얼로그에 갇혀 앱을 사용할 수 없게 됩니다.

접근성 (Accessibility)#

키보드 인터랙션#

동작
Escape다이얼로그 닫기 (onClose 가 있을 때만)
Tab다음 포커스 가능 요소로 이동
Shift+Tab이전 포커스 가능 요소로 이동
Enter포커스된 버튼 활성화

스크린 리더#

  • Flutter: dialog route 가 배리어와 함께 modal route 로 올라가고, FocusScope 가 배경 위젯을 traversal 에서 제외합니다
  • Web: 패널에 role="dialog" + aria-modal="true" 를 emit 합니다

알려진 제약#

  • 접근 가능한 이름을 잇는 파라미터가 없습니다aria-labelledby / aria-describedby 를 emit 하지 않으므로 title / content 가 다이얼로그의 이름·설명으로 프로그래밍적으로 연결되지 않습니다.
  • Web 에는 포커스 트랩이 없습니다 (위 포커스 관리 참고).
  • onClose 를 주지 않으면 Escape·스크림 클릭 둘 다 동작하지 않습니다 — 닫기 버튼을 trailing / actions 에 반드시 두세요.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.

크로스 플랫폼 차이점 (Platform Differences)#

파라미터 이름·순서·기본값은 양 플랫폼 동일합니다. 아래는 렌더 엔진 차이만 나열합니다.

항목FlutterWeb
클래스명DialogDialog
제목title: Widget?title: Component?
내용content: Widget?content: Component?
액션 actions: List<Widget>? actions: List<Component>?
스크림modal route 의 배리어배경 <div> + 클릭 핸들러
포커스FocusScope트랩 없음 (배경으로 Tab 이동 가능)
애니메이션ScaleTransition + FadeTransitionCSS 트랜지션
오버레이 마운트 dialog route (루트 Navigator) OverlayHost.dialog 레이어 portal — popover / menu / tooltip 위에 위치
ARIA FocusScope + modal route role="dialog", aria-modal="true"
  • Drawer: 화면 측면에서 슬라이드되는 패널. 복잡한 내용이나 긴 폼에 적합
  • Toast: 간단한 알림 메시지. 사용자 확인이 필요 없는 피드백에 사용
  • Popover: 특정 요소에 부착되는 작은 오버레이. 간단한 추가 정보 표시에 적합

조합 예제#

// 삭제 확인 패턴
Button(
  variant: CoreButtonVariant.destructive,
  onPressed: () => setState(() => _isDeleteDialogOpen = true),
  child: Text('삭제'),
)

// 다이얼로그 (위젯 트리에 배치)
Dialog(
  open: _isDeleteDialogOpen,
  title: Text('항목 삭제'),
  content: Text('선택한 ${items.length}개 항목을 삭제하시겠습니까?'),
  actions: [
    Button(
      variant: CoreButtonVariant.outline,
      onPressed: () => setState(() => _isDeleteDialogOpen = false),
      child: Text('취소'),
    ),
    Button(
      variant: CoreButtonVariant.destructive,
      onPressed: () {
        handleDelete();
        setState(() => _isDeleteDialogOpen = false);
      },
      child: Text('삭제'),
    ),
  ],
  onClose: () => setState(() => _isDeleteDialogOpen = false),
)