컴포넌트

Dialog

현재 화면 위에서 입력이나 작업을 수행합니다.

모션

97.5% 크기에서 300ms 동안 부드럽게 나타나고, 200ms 동안 작아지며 사라집니다. 중심 위치는 유지합니다. 움직임 감소 설정에서는 전환을 생략합니다. 공통 모션 규칙을 따릅니다.

설치

pnpm dlx ply-ui add dialog button input

공통 설치 설정을 먼저 적용합니다.

사용법

현재 화면 위에서 입력하거나 작업할 때 사용합니다. Title로 목적을 설명하고 openonOpenChange로 열림을 제어합니다. 예제의 저장 동작은 로컬 상태만 변경합니다.

구성

구성 요소역할
Root열림 상태를 관리합니다. openonOpenChange로 제어할 수 있습니다.
Trigger대화상자를 여는 버튼입니다.
Portal대화상자와 배경을 별도 영역에 렌더합니다.
Backdrop뒤 화면을 덮는 배경입니다.
Popup내용과 포커스를 담습니다. size로 너비를 지정합니다.
Title대화상자의 접근 가능한 이름입니다.
Description대화상자의 목적이나 입력 안내입니다.
Body본문 여백을 구성합니다. Popup의 직접 자식으로 둡니다.
Footer하단 작업 버튼의 여백과 정렬을 구성합니다.
Close닫기 동작을 연결한 버튼입니다.
CloseAffordancePopup 오른쪽 위의 닫기 아이콘 버튼입니다.

크기 SM

Popup size="sm"의 최대 너비는 320px입니다. 화면이 좁으면 화면 너비에 맞게 줄어듭니다.

크기 MD

Popup size="md"의 최대 너비는 480px입니다. 화면이 좁으면 화면 너비에 맞게 줄어듭니다. 기본값입니다.

크기 LG

Popup size="lg"의 최대 너비는 640px입니다. 화면이 좁으면 화면 너비에 맞게 줄어듭니다.

닫기 아이콘

CloseAffordance는 오른쪽 위 닫기 버튼입니다. aria-label로 작업 이름을 지정합니다.

속성

이 프로젝트에서 추가하거나 제한한 속성입니다.

속성

타입

구현 코드

components/ui/dialog.tsx
dialog.tsx
"use client";

import type { ComponentProps } from "react";
import { Dialog as Primitive } from "@base-ui/react/dialog";
import { cx, withClassName } from "../../lib/cx";
import { Icon } from "./icon";
import "./dialog.css";

// 동작과 접근성은 Base UI가 담당합니다. 이 파일은 스타일 연결만 담당합니다.
function DialogBackdrop({
  className,
  ...props
}: ComponentProps<typeof Primitive.Backdrop>) {
  return (
    <Primitive.Backdrop
      {...props}
      className={withClassName("rbx-backdrop", className)}
    />
  );
}

export type DialogPopupProps = ComponentProps<typeof Primitive.Popup> & {
  /** 대화상자 너비입니다. @defaultValue "md" */
  size?: "sm" | "md" | "lg";
};
function DialogPopup({ size = "md", className, ...props }: DialogPopupProps) {
  return (
    <Primitive.Popup
      {...props}
      data-size={size}
      className={withClassName("rbx-dialog", className)}
    />
  );
}

function DialogTitle({
  className,
  ...props
}: ComponentProps<typeof Primitive.Title>) {
  return (
    <Primitive.Title
      {...props}
      className={withClassName("rbx-dialog-title", className)}
    />
  );
}

function DialogDescription({
  className,
  ...props
}: ComponentProps<typeof Primitive.Description>) {
  return (
    <Primitive.Description
      {...props}
      className={withClassName("rbx-description", className)}
    />
  );
}

function DialogTrigger({
  className,
  ...props
}: ComponentProps<typeof Primitive.Trigger>) {
  return (
    <Primitive.Trigger
      {...props}
      className={withClassName("rbx-button", className)}
    />
  );
}

function DialogClose({
  className,
  ...props
}: ComponentProps<typeof Primitive.Close>) {
  return (
    <Primitive.Close
      {...props}
      className={withClassName("rbx-button", className)}
    />
  );
}

function DialogCloseAffordance({
  className,
  ...props
}: ComponentProps<typeof Primitive.Close>) {
  return (
    <Primitive.Close
      aria-label="Close"
      {...props}
      className={withClassName("rbx-dialog-close-affordance", className)}
    >
      <Icon name="icon-regular-x" size={20} />
    </Primitive.Close>
  );
}
/** Body와 Footer를 사용하면 Popup이 여백을 중복해서 만들지 않습니다. */
function DialogBody({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-dialog-body", className)} />;
}
function DialogFooter({ className, ...props }: ComponentProps<"div">) {
  return <div {...props} className={cx("rbx-dialog-footer", className)} />;
}
// Root/Portal 등 스타일 없는 파트와 제네릭 API는 원본을 그대로 보존합니다.
export const Dialog = {
  ...Primitive,
  Body: DialogBody,
  CloseAffordance: DialogCloseAffordance,
  Footer: DialogFooter,
  Backdrop: DialogBackdrop,
  Popup: DialogPopup,
  Title: DialogTitle,
  Description: DialogDescription,
  Trigger: DialogTrigger,
  Close: DialogClose,
};
components/ui/dialog.css
dialog.css
@import "./button.css";
/* Foundation Web Dialog: size controls width and 16/20px body/footer insets. */
.rbx-dialog {
  --rbx-dialog-inset: 20px;
}
.rbx-dialog[data-size="sm"] {
  width: min(320px, calc(100vw - 24px));
  --rbx-dialog-inset: 16px;
  padding: var(--rbx-dialog-inset);
}
.rbx-dialog[data-size="lg"] {
  width: min(640px, calc(100vw - 24px));
}
.rbx-dialog:has(> .rbx-dialog-body) {
  padding: 0;
}
.rbx-dialog-body {
  padding: var(--rbx-dialog-inset);
}
.rbx-dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 0 var(--rbx-dialog-inset) var(--rbx-dialog-inset);
}

.rbx-dialog-title {
  font: var(--rbx-typography-heading-small-font);
  letter-spacing: var(--rbx-typography-heading-small-letter-spacing);
}
.rbx-dialog-close-affordance {
  position: absolute;
  top: 12px;
  right: 12px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 8px;
  border: 0;
  border-radius: 9999px;
  background: var(--rbx-color-over-media-100);
  color: var(--rbx-color-content-emphasis);
  cursor: pointer;
}
.rbx-dialog-close-affordance:hover {
  background-image: linear-gradient(
    var(--rbx-color-state-hover),
    var(--rbx-color-state-hover)
  );
}
.rbx-dialog:has(.rbx-dialog-close-affordance) .rbx-dialog-title {
  padding-right: 36px;
}

목차