컴포넌트

Toast

작업 결과를 화면 아래 중앙에 잠시 표시합니다.

사용 기준

진행 중인 작업을 막지 않고 결과를 알려줄 때 사용합니다. 확인이 반드시 필요하면 Dialog나 Alert를 사용합니다.

새 알림은 현재 알림을 즉시 교체합니다. 교체된 알림은 다시 표시하지 않습니다. 실행 취소처럼 중요한 작업은 토스트가 사라진 뒤에도 목록이나 기록에서 실행할 수 있도록 제공합니다.

예제

상태

메시지로 결과를 설명하고, 성공과 오류에는 아이콘을 함께 표시합니다. 색상만으로 상태를 구분하지 않습니다.

설명이 있는 토스트

원인이나 다음 행동이 필요할 때만 설명을 덧붙입니다. 제목과 같은 내용을 반복하지 않습니다.

액션 토스트 · 실행 취소

관련 작업 하나를 버튼으로 제공합니다. 파일을 삭제한 뒤 실행 취소를 누르면 예제 목록에 파일이 복원됩니다. 이 목록은 예제 내부 상태이며 실제 파일을 삭제하지 않습니다.

액션 토스트 · 다시 시도

실패 상황을 재현한 뒤 다시 시도를 누르면 컴포넌트의 정적 레지스트리 파일을 요청합니다. 요청 중에는 자동으로 닫히지 않고, 결과에 따라 같은 알림을 갱신합니다.

액션 토스트 · 변경

추가 입력이 필요하면 별도 대화상자를 엽니다. 변경을 누르고 위치를 선택하면 예제의 파일 위치가 바뀝니다.

연속 알림

버튼을 연속으로 누르면 새 알림으로 교체됩니다. 여러 알림을 쌓거나 순서대로 기다리게 하지 않습니다.

표시 시간과 접근성

  • 기본 알림은 5초, 설명 예제는 8초, 액션 예제는 10초 동안 표시합니다. 표시 시간은 내용에 맞게 지정합니다.
  • 마우스를 올리거나 키보드 포커스를 알림 안으로 옮기면 자동 닫힘이 멈춥니다. 창을 벗어난 동안에도 타이머가 멈춥니다.
  • F6으로 알림 영역에 접근하고 Tab으로 작업 버튼과 닫기 버튼을 탐색합니다. 알림에 포커스가 있을 때 Escape로 닫을 수 있습니다.
  • 화면 읽기 프로그램에는 기본적으로 현재 읽는 내용을 방해하지 않는 우선순위로 알립니다. 긴급한 알림에만 priority: "high"를 지정합니다.
  • 타이머 일시정지는 자동 닫힘에 적용됩니다. 새 알림이 들어오면 상호작용 중인 알림도 교체됩니다.

모션

등장할 때 97.5% 크기에서 100%로 커지고, 아래 4px에서 제자리로 이동하며 300ms 동안 나타납니다. 자동 닫힘과 직접 닫힘은 모두 200ms 동안 작아지며 흐려집니다. 새 알림으로 교체할 때도 이전 알림의 퇴장과 새 알림의 등장을 함께 재생합니다. 이전 알림은 퇴장이 끝난 뒤 제거합니다.

운영체제의 움직임 감소 설정에서는 이동과 전환을 생략합니다. 곡선과 시간은 모션 문서에서 비교할 수 있습니다.

설치

pnpm dlx ply-ui add toast button dialog

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

사용법

앱에 Toast.ProviderToast.Toaster를 한 번 배치합니다. useToastManager는 Provider 안에서 호출합니다.

const manager = Toast.useToastManager();

manager.add({
  title: "파일을 삭제했습니다",
  timeout: 10000,
  actionProps: {
    children: "실행 취소",
    onClick: () => {
      restoreFile();
      manager.add({ title: "파일을 복원했습니다", type: "success" });
    },
  },
});

Action은 작업 후 자동으로 닫히지 않습니다. manager.close(id)로 닫거나 manager.add로 결과 알림을 표시합니다. 실제 처리가 성공한 뒤 성공 메시지를 표시하세요.

Toaster는 manager.toasts 목록을 기본 형태로 렌더합니다. 직접 구성하려면 Portal, Viewport, Root, Content 등의 부품을 사용합니다.

구성

구성 요소역할
Provider상태와 타이머를 관리하고 이전 알림을 닫습니다. 동시에 하나만 표시합니다.
ToasterPortal, Viewport와 각 알림의 기본 구성을 렌더합니다. 앱에 한 번 배치합니다.
useToastManageradd, update, close, promise로 알림을 관리합니다.
Portal / Viewport화면 아래 중앙에 알림 영역을 만듭니다.
Root알림 하나의 상태와 모션을 연결합니다.
Content메시지와 버튼을 가로로 배치합니다.
Title / Description결과 메시지와 선택적인 추가 설명입니다.
Action실행 취소·다시 시도 같은 후속 작업입니다.
Close알림을 닫는 버튼입니다.

속성

Provider

속성기본값설명
timeout5000자동 닫힘 시간(ms). 0이면 자동으로 닫히지 않습니다.
toastManagerReact 바깥에서 알림을 만들 때 사용할 전역 매니저입니다.

표시 개수는 하나로 고정합니다. 기본 Base UI Provider와 달리 교체된 알림을 닫으므로 onCloseonRemove가 호출됩니다.

manager.add

속성설명
title짧은 결과 메시지입니다.
description필요한 경우에만 붙이는 설명입니다.
type기본은 생략합니다. success, error는 상태 아이콘을 표시합니다.
timeout이 알림에만 적용할 표시 시간입니다.
actionProps버튼의 children, onClick, disabled 등입니다.
prioritylow가 기본이며 high는 즉시 읽어야 하는 알림에 사용합니다.
id같은 ID로 다시 추가하면 기존 알림을 갱신합니다.

Action / Close

버튼 속성과 이벤트를 전달할 수 있습니다. 직접 구성하는 Close에는 aria-label="알림 닫기"처럼 용도를 명시합니다. 기본 Toaster는 설명이나 액션이 있을 때만 닫기 버튼을 표시합니다. 한 줄 알림에는 표시하지 않습니다.

구현 코드

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

import { useEffect, type ComponentProps } from "react";
import { Toast as Primitive } from "@base-ui/react/toast";
import { withClassName } from "../../lib/cx";
import "./toast.css";

// limit만 줄이면 이전 알림이 다시 나타날 수 있습니다.
// 교체된 알림은 닫아 타이머와 상태도 함께 정리합니다.
function ReplacePreviousToast() {
  const { toasts, close } = Primitive.useToastManager();
  useEffect(() => {
    for (const toast of toasts) {
      if (toast.limited && toast.transitionStatus !== "ending") close(toast.id);
    }
  }, [toasts, close]);
  return null;
}

function ToastProvider({
  children,
  ...props
}: Omit<ComponentProps<typeof Primitive.Provider>, "limit">) {
  return (
    <Primitive.Provider {...props} limit={1}>
      <ReplacePreviousToast />
      {children}
    </Primitive.Provider>
  );
}

function ToastViewport({
  className,
  ...props
}: ComponentProps<typeof Primitive.Viewport>) {
  return (
    <Primitive.Viewport
      aria-label="알림"
      {...props}
      className={withClassName("rbx-toast-viewport", className)}
    />
  );
}
function ToastRoot({
  className,
  ...props
}: ComponentProps<typeof Primitive.Root>) {
  return (
    <Primitive.Root
      {...props}
      className={withClassName("rbx-toast", className)}
    />
  );
}
function ToastContent({
  className,
  ...props
}: ComponentProps<typeof Primitive.Content>) {
  return (
    <Primitive.Content
      {...props}
      className={withClassName("rbx-toast-content", className)}
    />
  );
}
function ToastTitle({
  className,
  ...props
}: ComponentProps<typeof Primitive.Title>) {
  return (
    <Primitive.Title
      {...props}
      className={withClassName("rbx-toast-title", className)}
    />
  );
}
function ToastDescription({
  className,
  ...props
}: ComponentProps<typeof Primitive.Description>) {
  return (
    <Primitive.Description
      {...props}
      className={withClassName("rbx-toast-description", className)}
    />
  );
}
function ToastClose({
  className,
  ...props
}: ComponentProps<typeof Primitive.Close>) {
  return (
    <Primitive.Close
      aria-hidden={false}
      {...props}
      className={withClassName("rbx-toast-close", className)}
    />
  );
}
function ToastAction({
  className,
  ...props
}: ComponentProps<typeof Primitive.Action>) {
  return (
    <Primitive.Action
      {...props}
      className={withClassName("rbx-toast-action", className)}
    />
  );
}

function StatusIcon({ type }: { type: string }) {
  return (
    <svg
      className="rbx-toast-icon"
      width="20"
      height="20"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9" />
      {type === "success" ? (
        <path d="m8 12 3 3 5-6" />
      ) : (
        <path d="M12 7v6m0 4h.01" />
      )}
    </svg>
  );
}

// 기본 구성을 한 곳에 두고, 사용하는 쪽에서는 메시지와 작업을 전달합니다.
function ToastToaster() {
  const { toasts } = Primitive.useToastManager();
  return (
    <Primitive.Portal>
      <ToastViewport>
        {toasts.map((toast) => (
          <ToastRoot key={toast.id} toast={toast}>
            <ToastContent>
              {(toast.type === "success" || toast.type === "error") && (
                <StatusIcon type={toast.type} />
              )}
              <div className="rbx-toast-text">
                <ToastTitle />
                <ToastDescription />
              </div>
              <ToastAction />
              {Boolean(toast.description || toast.actionProps?.children) && (
                <ToastClose aria-label="알림 닫기">
                  <svg
                    width="16"
                    height="16"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    aria-hidden="true"
                  >
                    <path d="m6 6 12 12M18 6 6 18" />
                  </svg>
                </ToastClose>
              )}
            </ToastContent>
          </ToastRoot>
        ))}
      </ToastViewport>
    </Primitive.Portal>
  );
}

// manager의 add/update/close/promise 및 전역 manager API는 Base UI 그대로 사용합니다.
export const Toast = {
  ...Primitive,
  Provider: ToastProvider,
  Toaster: ToastToaster,
  Viewport: ToastViewport,
  Root: ToastRoot,
  Content: ToastContent,
  Title: ToastTitle,
  Description: ToastDescription,
  Close: ToastClose,
  Action: ToastAction,
};
components/ui/toast.css
toast.css
@import "../../styles/motion.css";

/* 화면 아래 중앙. 긴 문장도 화면 너비 안에서 줄바꿈합니다. */
.rbx-toast-viewport {
  position: fixed;
  z-index: var(--rbx-layer-toast);
  inset-inline: 16px;
  bottom: max(24px, env(safe-area-inset-bottom));
  display: grid;
  justify-items: center;
  pointer-events: none;
  outline: none;
}
.rbx-toast {
  grid-area: 1 / 1;
  align-self: end;
  z-index: 1;
  width: fit-content;
  max-width: min(480px, 100%);
  min-width: 0;
  padding: 8px 8px 8px 16px;
  border: 1px solid var(--rbx-color-stroke-default);
  border-radius: var(--rbx-radius-medium);
  background: var(--rbx-color-surface-300);
  color: var(--rbx-color-content-emphasis);
  box-shadow: var(--rbx-shadow-transient-low);
  pointer-events: auto;
  touch-action: pan-x;
  opacity: 1;
  scale: 1;
  transform-origin: center;
  transform: translate(
    var(--toast-swipe-movement-x, 0px),
    var(--toast-swipe-movement-y, 0px)
  );
  transition:
    opacity var(--rbx-motion-duration-panel) var(--rbx-motion-ease-enter),
    scale var(--rbx-motion-duration-panel) var(--rbx-motion-ease-enter),
    transform var(--rbx-motion-duration-panel) var(--rbx-motion-ease-enter);
}
.rbx-toast:not(:has(.rbx-toast-close)) {
  padding-inline-end: 16px;
}
.rbx-toast[data-starting-style] {
  opacity: 0;
  scale: var(--rbx-motion-scale-overlay);
  transform: translateY(var(--rbx-motion-distance-toast));
}
/* 첫 화면을 그리기 전에 starting 상태가 해제돼도 등장 모션의 시작점을 제공합니다. */
@starting-style {
  .rbx-toast {
    opacity: 0;
    scale: var(--rbx-motion-scale-overlay);
    transform: translateY(var(--rbx-motion-distance-toast));
  }
}
.rbx-toast[data-ending-style] {
  opacity: 0;
  scale: var(--rbx-motion-scale-overlay);
  transform: translateY(var(--rbx-motion-distance-toast));
  transition-duration: var(--rbx-motion-duration-panel-exit);
  transition-timing-function: var(--rbx-motion-ease-exit);
  pointer-events: none;
}
.rbx-toast[data-swiping] {
  transition: none;
}
.rbx-toast[data-ending-style][data-swipe-direction="right"] {
  transform: translateX(calc(var(--toast-swipe-movement-x) + 100%));
}
.rbx-toast[data-ending-style][data-swipe-direction="down"] {
  transform: translateY(calc(var(--toast-swipe-movement-y) + 100%));
}
/* 교체된 알림도 퇴장이 끝날 때까지 남깁니다. 새 알림이 위에 그려집니다. */
.rbx-toast[data-limited] {
  z-index: 0;
  pointer-events: none;
}
.rbx-toast-content {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 32px;
}
.rbx-toast-text {
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
  padding-block: 4px;
}
.rbx-toast-title {
  margin: 0;
  font: var(--rbx-typography-body-small-font);
  font-weight: 500;
}
.rbx-toast-description {
  margin: 2px 0 0;
  font: var(--rbx-typography-body-small-font);
  color: var(--rbx-color-content-default);
}
.rbx-toast-icon {
  flex: 0 0 auto;
}
.rbx-toast[data-type="success"] .rbx-toast-icon {
  color: var(--rbx-color-system-success);
}
.rbx-toast[data-type="error"] .rbx-toast-icon {
  color: var(--rbx-color-system-alert);
}
.rbx-toast-action,
.rbx-toast-close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  min-height: 32px;
  border: 0;
  border-radius: var(--rbx-radius-medium);
  background: transparent;
  color: inherit;
  cursor: pointer;
}
.rbx-toast-action {
  padding: 4px 8px;
  font: var(--rbx-typography-body-small-font);
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.rbx-toast-close {
  width: 32px;
  padding: 0;
  color: var(--rbx-color-content-default);
}
:is(.rbx-toast-action, .rbx-toast-close):hover {
  background: var(--rbx-color-state-hover);
}
:is(.rbx-toast-action, .rbx-toast-close):active {
  background: var(--rbx-color-state-press);
}
@media (max-width: 400px) {
  .rbx-toast-content {
    gap: 8px;
  }
}
@media (prefers-reduced-motion: reduce) {
  .rbx-toast {
    transition: none;
    transform: none;
    scale: none;
  }
}

목차