Dialog
모달 오버레이로 사용자의 확인이나 입력을 받는 컴포넌트. 플랫폼에 따라 variant 구성이 다름.
Aliases (deprecated): Popup, KeyboardAvoidingPopup — 모두 Dialog로 이관.
Platform Variants
Mobile (2 presentations)
default/ BottomSheet — title + body + primary + secondary. dismiss는 primary/secondary 버튼으로만 (X 버튼 없음)popup— 스크립트 speaker 지정 popup처럼 현재 화면 맥락 위에 중앙 popup으로 뜨는 작은 폼/선택 케이스에 사용
이미지가 필요한 promotional 콘텐츠는 Sheet variant="media" 사용. 긴 폼·리스트·키보드 입력은 BottomSheet, 현재 맥락에서 끝나는 작은 폼은 popup presentation을 사용한다. trigger 주변의 짧은 액션 메뉴는 Popover의 Inline Popover로 분리한다.
Desktop/Web (2 variants)
text— title + body + primary + secondary (가로 2 버튼). 일반 확인·안내·입력 모달media— 상단 이미지 영역(h-80) + title + body + primary + secondary. 온보딩, 리브랜드 안내, 페이지 indicator 포함 가능
두 variant 모두 같은 container language를 공유한다. Desktop 기본 폭은 560px 안쪽, rounded-[24px], 부드러운 overlay blur 기준.
Mobile Presentation
모바일에서 Dialog는 맥락에 따라 BottomSheet 또는 Popup으로 표현된다. 데스크탑의 중앙 modal과는 anatomy가 다르지만 시맨틱(확인·입력·안내·짧은 선택)은 동일하므로 같은 Dialog 계열로 다룬다.
- 위치: 화면 정중앙 (overlay 위에 떠 있음)
- 너비:
max-w-[560px], 작은 화면은w-[calc(100vw-32px)] - 모서리:
rounded-[24px](4면 모두) - Overlay:
stone-950/50+backdrop-blur-[2px](optional) - Dismiss: Esc · overlay click · X 버튼(primary-only) · secondary 버튼
- Focus: focus-trap 활성, 첫 focus는 title 또는 첫 input
Library mapping
| 플랫폼 | 권장 라이브러리 | 비고 |
|---|---|---|
| Web | Radix UI Dialog | 기존 shadcn/ui 기반 |
| iOS | @lodev09/react-native-true-sheet | native sheet presentation, detents·grabber 내장 |
| Android | 동일 (react-native-true-sheet) | Android는 detent fraction 표기 ([0.9, 1]) |
| Mobile popup | GlobalPopup + RN Modal | 스크립트 speaker 지정처럼 중앙 popup |
preview-mobile
preview-mobile
BottomSheet — text variant, primary + secondary
preview-mobile
Popup — speaker assignment
Preview
헤더(제목+탭)와 푸터를 고정하고 중간 body만 스크롤하는 다중 섹션 다이얼로그 패턴이다. 섹션 구분선(border-t border-stone-200)으로 의미 단위를 나누고, 각 섹션 헤더는 좌측 레이블 + 우측 설명 2열로 구성할 수 있다.
Close-only (X 버튼)
하단에 취소/나중에 버튼 없이 primary action 하나만 있을 때, 우측 상단 X 버튼이 유일한 dismiss 수단이 된다. 완료 안내, 단순 확인, 정보성 모달에 사용한다.
Anatomy
Desktop Dialog
DialogRoot
├─ DialogOverlay (optional, 배경 dim — 필요한 경우에만)
└─ DialogContent (모달 컨테이너)
├─ DialogMedia (optional, desktop variant='media' 전용)
│ └─ image | illustration (full width, flush top)
├─ DialogHeader (optional)
│ ├─ DialogTitle (Text · subtitle1SemiBold)
│ └─ DialogDescription (optional, Text · body1Regular)
├─ DialogBody (optional, 자유 콘텐츠 영역)
├─ DialogPagination (optional, desktop media multi-step)
│ └─ Pagination primitive (variant='dot', max 3)
├─ DialogFooter (필수)
│ ├─ PrimaryAction (Button — brown-800)
│ └─ SecondaryAction (optional, Button outlined variant)Mobile Dialog
모바일 Dialog는 표시 방식에 따라 BottomSheet와 Popup으로 나뉜다.
BottomSheet
모바일 기본 Dialog는 화면 하단에서 올라오는 BottomSheet다. title/body/action 구조는 desktop Dialog와 동일하지만 container, radius, action layout이 다르다.
BottomSheetRoot
└─ SheetContainer (TrueSheet, full-width)
├─ SheetGrabber (optional, drag 가능한 시트에서만)
├─ SheetHeader (optional)
│ ├─ SheetTitle (Text · subtitle2 / subtitle1)
│ └─ SheetDescription (optional)
├─ SheetBody (optional, form / list / free content, scrollable)
└─ SheetActions (필수)
├─ PrimaryAction (Button lg, full-width)
└─ SecondaryAction (optional, Button outlined, full-width)| Token | Value |
|---|---|
| container | TrueSheet, backgroundColor="#ffffff" |
| width | 100vw, full-width |
| radius | top corners only, 기본 cornerRadius: 16, folder sheet 20 |
| detents | short auto, long [auto, 1], folder selection [0.4, 1] |
| body | ScrollView 또는 scrollable content, safe-area / keyboard aware |
| actions | vertical stack, full-width, gap 6–8 |
| dismiss | drag down, outside tap, hardware back, primary / secondary action |
Centered Popup
스크립트에서 speaker를 지정할 때처럼 현재 화면 맥락 위에 작은 폼을 띄운다. 구조는 BottomSheet와 유사하지만 화면 하단 시트가 아니라 중앙 popup으로 표현된다. FolderScreen 상단 +처럼 trigger 주변에 뜨는 짧은 액션 메뉴는 Popover의 Inline Popover로 분리한다.
PopupFormRoot
├─ PopupOverlay (GlobalPopup backdrop, rgba(0,0,0,0.3))
└─ PopupContent (centered card)
├─ PopupTitle (subtitle1_18, center)
├─ PopupBody (scrollable; input / selected card / options)
└─ PopupActions (vertical primary + secondary)SpeakerLabelPopup 실제 값:
| Token | Value |
|---|---|
| wrapper | GlobalPopup + RN Modal, centered |
| backdrop | rgba(0, 0, 0, 0.3) |
| popup width | 350 |
| popup radius | 10 |
| popup padding | px-[20px] py-[30px] |
| popup border | grayscale[50] |
| title | Typography.subtitle1_18, center |
| body | ScrollView, nestedScrollEnabled, keyboardShouldPersistTaps="always" |
| input | PersonTagInput, select mode |
| selected card | border warmGray-100, radius 10, padding 14 |
| scope options | radio row, py-[6px], label 16/24 SemiBold |
| actions | vertical stack, gap 6, primary brown-800, secondary warmGray-100 |
Multi-section layout
폼 필드나 설정 항목이 여러 개의 의미 있는 그룹으로 나뉠 때 사용하는 DialogBody 구성 패턴이다. DialogContent에 flex flex-col과 max-h를 추가해 헤더·푸터를 고정하고 중간 body만 스크롤한다.
DialogContent (flex flex-col max-h-[calc(100vh-64px)])
├─ 고정 헤더 (shrink-0)
│ ├─ DialogTitle (+optional subtitle)
│ └─ SegmentedControl (optional, 컨텍스트 전환 — 최대 3탭, 각 flex-1)
├─ 스크롤 body (flex-1 overflow-y-auto) ← padding은 각 section이 담당
│ ├─ Section (px-7 pt-1 pb-6 space-y-4)
│ ├─ 구분선 (border-t border-stone-200)
│ └─ Section (px-7 pt-5 pb-6 space-y-3)
└─ 고정 푸터 (shrink-0 flex items-center justify-end px-7 py-4)
└─ PrimaryAction + SecondaryAction섹션 간 의미 단위가 충분히 다를 때 mx-7 border-t border-stone-200 구분선으로 나눈다. 섹션 레이블은 List 섹션 라벨과 동일하게 caption1Medium text-stone-400를 사용한다.
<div className="flex-1 overflow-y-auto">
<section className="space-y-4 px-7 pb-6 pt-1">
<p className="caption1Medium text-stone-400">노트 기본값</p>
{/* section 1 */}
</section>
<div className="mx-7 border-t border-stone-200" />
<section className="space-y-4 px-7 pb-6 pt-5">
<div className="mb-4 flex items-center justify-between">
<p className="caption1Medium text-stone-400">자동 공유</p>
<p className="caption1Regular text-stone-400">선택</p>
</div>
{/* section 2 */}
</section>
</div>| 영역 | 클래스 |
|---|---|
| DialogContent 추가 클래스 | flex flex-col max-h-[calc(100vh-64px)] |
| 고정 헤더 | shrink-0 px-7 pt-7 pb-5 |
| 스크롤 body | flex-1 overflow-y-auto (padding은 section 담당) |
| section padding (첫 번째) | px-7 pt-1 pb-6 space-y-4 |
| section padding (이후) | px-7 pt-5 pb-6 space-y-3 |
| 섹션 구분선 | mx-7 border-t border-stone-200 |
| 고정 푸터 | shrink-0 flex items-center justify-end px-7 py-4 |
| section label | caption1Medium text-stone-400 |
슬롯 규칙
| Slot | Required | Primitive | 비고 |
|---|---|---|---|
| DialogOverlay | optional | — | stone-950/50 + blur — 필요한 컨텍스트에서만 사용 |
| DialogContent | always | — | width: mobile full with inset, desktop max 560px |
| DialogMedia | desktop variant='media'만 | image / illustration | h-48–h-56, 컨테이너 상단 flush |
| DialogTitle | recommended | Text | subtitle1SemiBold |
| DialogDescription | optional | Text | body1Regular |
| DialogBody | optional | (자유) | form / 기타 콘텐츠 |
| DialogPagination | optional | Pagination | desktop media + multi-step에만 |
| PrimaryAction | always | Button | desktop 기본은 button2Medium, mobile CTA는 button1Medium |
| SecondaryAction | optional | Button (outlined variant) | bg-transparent text-brown-800 border-stone-200 |
| SheetContainer | mobile bottomsheet만 | TrueSheet | detents, cornerRadius, grabber, scrollable 영역 관리 |
| SheetBody | mobile bottomsheet만 | ScrollView / View | form / list / free content. 길어지면 body만 스크롤 |
| SheetActions | mobile bottomsheet만 | Button stack | full-width vertical primary + secondary |
| PopupTitle | centered popup만 | Text | speaker 지정처럼 BottomSheet와 유사한 title/body/action 구조 |
| PopupBody | centered popup만 | ScrollView | 작은 폼·선택 옵션. 내용이 길어지면 BottomSheet로 전환 |
| PopupActions | centered popup만 | Button stack | 세로 primary + secondary, gap 6px |
Layout invariants
- Mobile: 버튼 영역은 세로 스택, gap 8px, 각 full-width. 기본은 Button
lg스케일 (button1Medium) - Mobile centered popup: speaker 지정처럼 작은 폼은 title/body/action 구조를 사용한다. body는 스크롤 가능하지만 긴 리스트·복잡한 편집은 BottomSheet로 전환한다.
- Mobile inline action menu: 1–3개 짧은 trigger 액션은 Dialog가 아니라
PopoverInline Popover를 사용한다. - Desktop: 버튼 영역은 가로 2버튼, gap 8px, 자연 폭 + 우측 정렬. 기본은 Button
md스케일 (button2Medium), 각 액션은min-width 120px - Media variant: media → header → body → pagination → footer 순서 고정
- Primary CTA 하나만 있을 때: desktop은 자연 폭 유지, mobile은 full-width 유지
- Dismiss 구조 — secondary action 있음: 하단 취소/나중에 버튼이 dismiss 역할을 하므로 X 버튼을 추가하지 않는다
- Dismiss 구조 — primary only: secondary action 없이 primary 하나만 있을 때 우측 상단 X 버튼 필수. Button
ghostsmicon-only 스펙 사용 —absolute right-5 top-5 flex h-8 w-8 items-center justify-center rounded-lg text-stone-400 hover:bg-stone-50,X size={16} strokeWidth={1.75}. title 영역에pr-6을 주어 X 버튼과 겹치지 않게 한다 - Full-surface preview: 문서 preview는 optional slot을 한 번에 보여주기 위해 media/text/input 성격을 결합해 설명용으로 렌더
Spec
| Token | Value |
|---|---|
| container bg | #FFFFFF |
| border radius | rounded-[24px] |
| elevation | shadow-[0_24px_80px_rgba(28,25,23,0.16)] |
| overlay (optional) | stone-950/50 + backdrop-blur-[2px] |
| section label | caption1Medium text-stone-400 |
| section gap | space-y-6 |
| footer padding | px-7 py-4 (border 없음) |
| title | subtitle1SemiBold (18/27, SemiBold) |
| description | body1Regular (14/20, Regular) |
| field label | label1Medium |
| helper text | caption1Regular |
| desktop primary button | bg-brown-800, white, button2Medium, rounded-lg, height 40, min-width 120 |
| desktop secondary button | bg-transparent text-brown-800 border-stone-200, button2Medium, rounded-lg, height 40, min-width 120 |
| Token | Value |
|---|---|
| container width | max-w-[560px], w-[calc(100vw-32px)] |
| content padding | px-7 py-7 |
| corner radius | rounded-[24px] (4면) |
| button layout (2버튼) | 가로, 자연 폭 + 우측 정렬 |
| button gap | gap-2 (8px) |
| button height | 40px (Button md) |
| button min width | 120px |
image area (media variant) | h-48–h-56, full width |
Props
type DialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
variant?: 'default' | 'popup' | 'text' | 'media' // mobile: default/popup, desktop: text/media
title?: string
description?: string
image?: ReactNode // desktop variant='media' 전용
pageIndicator?: { current: number; total: number }
primaryAction: { label: string; onClick: () => void }
secondaryAction?: { label: string; onClick: () => void }
dismissOnOverlayClick?: boolean // default true
}Usage
// Desktop — text variant
// primaryAction → Button variant="default" (brown-800)
// secondaryAction → Button variant="outline" (항상. ghost/secondary 사용 금지)
<Dialog
variant="text"
open={open}
onOpenChange={setOpen}
title="노트 이름 변경"
description="새로운 이름을 입력해 주세요."
primaryAction={{ label: "저장", onClick: save }}
secondaryAction={{ label: "취소", onClick: cancel }}
/>
// Desktop — media variant (온보딩)
<Dialog
variant="media"
open={open}
onOpenChange={setOpen}
image={<OnboardingIllustration />}
title="새로워진 티로를 소개합니다."
description="본질을 담은 새 로고"
pageIndicator={{ current: 1, total: 2 }}
primaryAction={{ label: "다음", onClick: next }}
secondaryAction={{ label: "나중에", onClick: dismiss }}
/>A11y
- Focus trap 활성화 (Tab이 Dialog 내부 순환)
- 첫 focus는 title 또는 첫 input — 파괴적 액션에 초기 focus 금지
- Esc 키로 닫기
- Overlay 클릭으로 닫기 (
dismissOnOverlayClick=true기본)
Do / Don't
- Do: 정보, 편집, 확인이 필요한 순간에 사용
- Do: Primary는 항상
brown-800(Buttondefaultvariant) - Do: Secondary(취소/나중에) 버튼은 반드시
outlinevariant —bg-transparent border-stone-200 text-brown-800.ghost나secondary를 사용하지 않는다 - Do: 모바일은 세로 버튼 스택, 데스크탑은 자연 폭 버튼 + 우측 정렬
- Do: form body가 들어오면
label1Medium/body1Regular/caption1Regular계층 유지 - Do: 이미지가 필요한 모바일 콘텐츠는
Sheet variant="media"사용 - Do: secondary action 없이 primary만 있을 때 반드시 X 버튼 추가 — dismiss 수단이 없으면 안 됨
- Do: 모바일에서 1–3개 짧은 trigger 액션 선택은
PopoverInline Popover 사용 - Do: speaker 지정처럼 현재 스크립트 맥락에서 끝나는 작은 폼은 centered popup 사용
- Don't: secondary action이 있는데 X 버튼을 함께 두지 말 것 — dismiss 경로가 3개가 되어 의도 불명확
- Do: 여러 섹션이 필요하면
flex flex-col max-h-[calc(100vh-64px)]로 스크롤 구조를 만들고, 헤더·푸터를shrink-0으로 고정한다 - Do: 섹션 레이블은
caption1Medium text-stone-400— List 섹션 라벨과 동일 스타일 - Do: 다이얼로그 내 컨텍스트 전환이 필요하면 최대 3탭의 segmented tabs 사용
- Don't: 파괴적 확인은
AlertDialog사용 - Don't: 폼 필드 3개 초과 → 별도 페이지
- Don't: Dialog 안에 Dialog 중첩 금지
- Don't: 모바일에
text/mediavariant 사용 금지 —Sheet로 전환 - Don't: centered popup에 긴 리스트·복잡한 편집·전체 화면 키보드 작업을 넣지 말 것 — BottomSheet로 전환
