Tiro
Tiro Design
Components

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

플랫폼권장 라이브러리비고
WebRadix UI Dialog기존 shadcn/ui 기반
iOS@lodev09/react-native-true-sheetnative sheet presentation, detents·grabber 내장
Android동일 (react-native-true-sheet)Android는 detent fraction 표기 ([0.9, 1])
Mobile popupGlobalPopup + 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는 표시 방식에 따라 BottomSheetPopup으로 나뉜다.

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)
TokenValue
containerTrueSheet, backgroundColor="#ffffff"
width100vw, full-width
radiustop corners only, 기본 cornerRadius: 16, folder sheet 20
detentsshort auto, long [auto, 1], folder selection [0.4, 1]
bodyScrollView 또는 scrollable content, safe-area / keyboard aware
actionsvertical stack, full-width, gap 68
dismissdrag 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 실제 값:

TokenValue
wrapperGlobalPopup + RN Modal, centered
backdroprgba(0, 0, 0, 0.3)
popup width350
popup radius10
popup paddingpx-[20px] py-[30px]
popup bordergrayscale[50]
titleTypography.subtitle1_18, center
bodyScrollView, nestedScrollEnabled, keyboardShouldPersistTaps="always"
inputPersonTagInput, select mode
selected cardborder warmGray-100, radius 10, padding 14
scope optionsradio row, py-[6px], label 16/24 SemiBold
actionsvertical stack, gap 6, primary brown-800, secondary warmGray-100

Multi-section layout

폼 필드나 설정 항목이 여러 개의 의미 있는 그룹으로 나뉠 때 사용하는 DialogBody 구성 패턴이다. DialogContentflex flex-colmax-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
스크롤 bodyflex-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 labelcaption1Medium text-stone-400

슬롯 규칙

SlotRequiredPrimitive비고
DialogOverlayoptionalstone-950/50 + blur — 필요한 컨텍스트에서만 사용
DialogContentalwayswidth: mobile full with inset, desktop max 560px
DialogMediadesktop variant='media'만image / illustrationh-48h-56, 컨테이너 상단 flush
DialogTitlerecommendedTextsubtitle1SemiBold
DialogDescriptionoptionalTextbody1Regular
DialogBodyoptional(자유)form / 기타 콘텐츠
DialogPaginationoptionalPaginationdesktop media + multi-step에만
PrimaryActionalwaysButtondesktop 기본은 button2Medium, mobile CTA는 button1Medium
SecondaryActionoptionalButton (outlined variant)bg-transparent text-brown-800 border-stone-200
SheetContainermobile bottomsheet만TrueSheetdetents, cornerRadius, grabber, scrollable 영역 관리
SheetBodymobile bottomsheet만ScrollView / Viewform / list / free content. 길어지면 body만 스크롤
SheetActionsmobile bottomsheet만Button stackfull-width vertical primary + secondary
PopupTitlecentered popup만Textspeaker 지정처럼 BottomSheet와 유사한 title/body/action 구조
PopupBodycentered popup만ScrollView작은 폼·선택 옵션. 내용이 길어지면 BottomSheet로 전환
PopupActionscentered 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가 아니라 Popover Inline 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 ghost sm icon-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

TokenValue
container bg#FFFFFF
border radiusrounded-[24px]
elevationshadow-[0_24px_80px_rgba(28,25,23,0.16)]
overlay (optional)stone-950/50 + backdrop-blur-[2px]
section labelcaption1Medium text-stone-400
section gapspace-y-6
footer paddingpx-7 py-4 (border 없음)
titlesubtitle1SemiBold (18/27, SemiBold)
descriptionbody1Regular (14/20, Regular)
field labellabel1Medium
helper textcaption1Regular
desktop primary buttonbg-brown-800, white, button2Medium, rounded-lg, height 40, min-width 120
desktop secondary buttonbg-transparent text-brown-800 border-stone-200, button2Medium, rounded-lg, height 40, min-width 120
TokenValue
container widthmax-w-[560px], w-[calc(100vw-32px)]
content paddingpx-7 py-7
corner radiusrounded-[24px] (4면)
button layout (2버튼)가로, 자연 폭 + 우측 정렬
button gapgap-2 (8px)
button height40px (Button md)
button min width120px
image area (media variant)h-48h-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 (Button default variant)
  • Do: Secondary(취소/나중에) 버튼은 반드시 outline variant — bg-transparent border-stone-200 text-brown-800. ghostsecondary를 사용하지 않는다
  • Do: 모바일은 세로 버튼 스택, 데스크탑은 자연 폭 버튼 + 우측 정렬
  • Do: form body가 들어오면 label1Medium / body1Regular / caption1Regular 계층 유지
  • Do: 이미지가 필요한 모바일 콘텐츠는 Sheet variant="media" 사용
  • Do: secondary action 없이 primary만 있을 때 반드시 X 버튼 추가 — dismiss 수단이 없으면 안 됨
  • Do: 모바일에서 1–3개 짧은 trigger 액션 선택은 Popover Inline 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 / media variant 사용 금지 — Sheet로 전환
  • Don't: centered popup에 긴 리스트·복잡한 편집·전체 화면 키보드 작업을 넣지 말 것 — BottomSheet로 전환

On this page