MENU
Wwoon

Toast

Toast

자동으로 사라지는 알림 메시지. 어디서든 호출할 수 있는 명령형 API입니다.

설치

npm install @woon-ui/toast
import { toast, Toaster } from '@woon-ui/toast'
import '@woon-ui/toast/css'

개요

import { toast, Toaster } from '@woon-ui/toast'

toast()는 어디서든 호출할 수 있는 모듈 레벨 함수입니다. 앱 루트에 Toaster를 한 번 렌더하면 됩니다.

설정

Toaster

import { Toaster } from '@woon-ui/toast'

<>
  <App />
  <Toaster position="bottom-right" />
</>
PropTypeDefault
position'top-left'|'top-center'|'top-right'|'bottom-left'|'bottom-center'|'bottom-right''bottom-right'
토스트가 나타나는 위치입니다.
maxVisiblenumber3
동시에 표시할 최대 토스트 수. 초과 시 가장 오래된 토스트부터 제거됩니다.
zIndexnumber9000
토스트 컨테이너의 z-index입니다.
renderComponentType<ToastDefaultRenderProps>내장 UI
기본 토스트 UI를 교체할 커스텀 컴포넌트입니다.

단독 사용

<Toaster />는 toast runtime을 마운트하는 컴포넌트입니다.

import { Toaster } from '@woon-ui/toast'

function App() {
  return (
    <>
      <YourApp />
      <Toaster position="bottom-right" />
    </>
  )
}

Toasterposition, maxVisible, zIndex, render를 전달해 동작과 기본 UI를 설정할 수 있습니다.

기본 사용법

import { toast } from '@woon-ui/toast'

toast({ title: '저장되었습니다' })

toast({ title: '변경사항 저장됨', description: '모든 변경사항이 저장되었습니다.' })

Preview

ToastContent

첫 번째 인자로 전달하는 콘텐츠입니다.

PropTypeDefault
titleReactNode
토스트 제목
descriptionReactNode
부가 설명
action{ label: ReactNode; onClick: () => void }
액션 버튼. 클릭 시 onClick 실행 후 토스트가 자동으로 닫힙니다.

옵션

두 번째 인자로 표시 동작을 제어합니다.

toast({ title: '오류 발생' }, { tone: 'danger', duration: 8000 })
PropTypeDefault
tone'default'|'danger''default'
시각적 톤
durationnumber5000
자동 닫힘 시간(ms). 최솟값 2000ms. Infinity로 설정하면 수동으로 닫을 때까지 유지됩니다.

Preview

액션 버튼

action으로 버튼을 추가할 수 있습니다. 클릭 시 onClick 실행 후 토스트가 자동으로 닫힙니다.

toast(
  {
    title: '항목이 삭제되었습니다',
    action: {
      label: '되돌리기',
      onClick: () => restoreItem(),
    },
  },
  { duration: Infinity },
)

Tip

액션이 있는 토스트는 duration: Infinity로 설정하는 것을 권장합니다. 사용자가 버튼을 누를 시간이 필요합니다.

Preview

토스트 업데이트

toast()는 핸들 객체를 반환합니다. update()로 내용을 바꾸고 close()로 수동으로 닫을 수 있습니다.

const handle = toast({ title: '업로드 중...' }, { duration: Infinity })

// 내용 업데이트
handle.update({ title: '업로드 완료!' })

// 수동 닫기
handle.close()

ToastHandle

PropTypeDefault
idstring
토스트 고유 ID
close() => void
토스트 수동 닫기
update(content: ToastContent) => void
표시 중인 토스트의 내용을 업데이트합니다

Preview

스택 동작

  • maxVisible (기본 3)을 초과하면 가장 오래된 토스트가 사라집니다
  • 기본 상태에서 토스트는 스택으로 겹쳐 보입니다
  • hover 시 전체 목록이 펼쳐집니다 (expanded)
  • 큐 없이 overflow는 oldest-drop 방식입니다

Preview

위치

ToasterPosition은 6가지를 지원합니다.

위치
좌상단'top-left'
상단 중앙'top-center'
우상단'top-right'
좌하단'bottom-left'
하단 중앙'bottom-center'
우하단'bottom-right'
<Toaster position="top-center" />

커스텀 렌더링

render prop (컴포넌트 등록)

<Toaster render={...} />로 기본 토스트 UI를 교체할 수 있습니다.

import { Toaster } from '@woon-ui/toast'
import { MyToast } from './ui/Toast'

<Toaster render={MyToast} />

ToastDefaultRenderProps 타입을 구현하면 됩니다:

type ToastDefaultRenderProps = {
  title: ReactNode
  description?: ReactNode
  action?: { label: ReactNode; onClick: () => void }
  close: () => void
}

함수 문법 (완전 커스텀)

toast()에 함수를 전달하면 렌더링을 완전히 제어할 수 있습니다.

toast((ctx) => (
  <div>
    <p>커스텀 UI</p>
    <button onClick={ctx.close}>닫기</button>
  </div>
))

스타일

@woon-ui/toast/css로 기본 스타일이 적용됩니다. 특정 값만 바꾸려면 선택자로 override하세요.

[data-woon-toast] {
  background: #1a1a1a;
  color: #fff;
  border-radius: 12px;
}

완전히 교체하려면 @woon-ui/react/css import를 제거하고 아래 CSS를 복사해서 수정하세요.

/* Toaster 컨테이너 & 스택 애니메이션 */
[data-woon-toaster] {
  position: fixed;
  width: min(calc(100vw - 2rem), 24rem);
  pointer-events: none;
}

[data-woon-toaster][data-position="top-left"]      { top: 1rem; left: 1rem; }
[data-woon-toaster][data-position="top-right"]     { top: 1rem; right: 1rem; }
[data-woon-toaster][data-position="top-center"]    { top: 1rem; left: 50%; translate: -50% 0; }
[data-woon-toaster][data-position="bottom-left"]   { bottom: 1rem; left: 1rem; }
[data-woon-toaster][data-position="bottom-right"]  { bottom: 1rem; right: 1rem; }
[data-woon-toaster][data-position="bottom-center"] { bottom: 1rem; left: 50%; translate: -50% 0; }

[data-woon-toaster][data-position^="bottom"] { --lift: -1; --lift-amount: calc(var(--lift) * 14px); }
[data-woon-toaster][data-position^="top"]    { --lift:  1; --lift-amount: calc(var(--lift) * 14px); }

[data-woon-toast-wrapper] {
  position: absolute;
  width: 100%;
  opacity: 0;
  --y: translateY(calc(var(--lift) * -100%));
  transform: var(--y);
  transition: transform 400ms, opacity 400ms, height 400ms;
}

[data-woon-toaster][data-position^="bottom"] [data-woon-toast-wrapper] { bottom: 0; transform-origin: bottom center; }
[data-woon-toaster][data-position^="top"]    [data-woon-toast-wrapper] { top: 0;    transform-origin: top center; }

[data-woon-toaster]:not([data-expanded]) [data-woon-toast-wrapper]:not([data-mounted]):not([data-front]) {
  --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--toasts-before) * 0.05 + 1));
}

[data-woon-toaster] [data-woon-toast-wrapper][data-mounted] { --y: translateY(0); opacity: 1; }

[data-woon-toaster]:not([data-expanded]) [data-woon-toast-wrapper][data-mounted]:not([data-front]) {
  --y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--toasts-before) * 0.05 + 1));
}

[data-woon-toaster]:not([data-expanded]) [data-woon-toast-wrapper][data-mounted]:not([data-front]) [data-woon-toast] > * {
  opacity: 0;
  transition: opacity 400ms;
}

[data-woon-toaster][data-expanded] [data-woon-toast-wrapper][data-mounted] { --y: translateY(calc(var(--lift) * var(--offset))); opacity: 1; }
[data-woon-toaster][data-expanded] [data-woon-toast-wrapper][data-mounted] [data-woon-toast] > * { opacity: 1; transition: opacity 400ms; }

[data-woon-toaster] [data-woon-toast-wrapper][data-state="closed"][data-front] { --y: translateY(calc(var(--lift) * -100%)); opacity: 0; }
[data-woon-toaster][data-expanded] [data-woon-toast-wrapper][data-state="closed"]:not([data-front]) { --y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%)); opacity: 0; }
[data-woon-toaster]:not([data-expanded]) [data-woon-toast-wrapper][data-state="closed"]:not([data-front]) { opacity: 0; transition: opacity 250ms ease-in; }

/* Toast 아이템 */
[data-woon-toast],
[data-woon-toast] *,
[data-woon-toast] *::before,
[data-woon-toast] *::after {
  box-sizing: border-box;
}

[data-woon-toast] {
  padding: 0.75rem 1rem;
  background: #fff;
  color: #000;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 0.875rem;
  line-height: 1.5;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

[data-woon-toast][data-tone="danger"] { background: #c0392b; color: #fff; }

/* Toast 콘텐츠 */
[data-woon-toast-body]        { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; min-width: 0; }
[data-woon-toast-title]       { font-weight: 500; line-height: 1.4; }
[data-woon-toast-description] { font-size: 0.8125rem; opacity: 0.65; line-height: 1.4; }

[data-woon-toast-actions] { display: flex; align-items: center; gap: 0.125rem; flex-shrink: 0; }

[data-woon-toast-action] {
  -webkit-appearance: none; appearance: none; box-sizing: border-box;
  font-size: 0.8125rem; font-weight: 600; font-family: inherit;
  color: #3b82f6; background: none; border: none; cursor: pointer;
  padding: 0.25rem 0.375rem; border-radius: 4px; line-height: 1; white-space: nowrap;
}
[data-woon-toast-action]:hover { background: rgba(59, 130, 246, 0.08); }

[data-woon-toast-close] {
  -webkit-appearance: none; appearance: none; box-sizing: border-box;
  display: flex; align-items: center; justify-content: center;
  width: 1.5rem; height: 1.5rem; background: none; border: none; cursor: pointer;
  border-radius: 4px; font-size: 0.6875rem; font-family: inherit; color: inherit; opacity: 0.4; line-height: 1;
}
[data-woon-toast-close]:hover { opacity: 0.8; background: rgba(0, 0, 0, 0.06); }

[data-tone='danger'] [data-woon-toast-action]       { color: rgba(255, 255, 255, 0.9); }
[data-tone='danger'] [data-woon-toast-action]:hover { background: rgba(255, 255, 255, 0.12); }
[data-tone='danger'] [data-woon-toast-close]:hover  { background: rgba(255, 255, 255, 0.12); }

@media (prefers-reduced-motion) {
  [data-woon-toast-wrapper], [data-woon-toast-wrapper] > * { transition: none !important; }
}

접근성

  • 토스트 컨테이너에 aria-live="polite" 자동 적용
  • hover 또는 focus 시 auto-dismiss 타이머 일시정지
  • 타이머 재개 시 duration 전체 리셋 (잔여 시간이 아닌 전체 시간)
  • duration 최솟값 2000ms 강제 (너무 짧으면 접근성 위반)
  • ESC 키로 가장 앞의 토스트 닫기

data 속성

속성대상
data-woon-toasterToaster 컨테이너
data-positionToaster 컨테이너위치값
data-expandedToaster 컨테이너hover 시 존재
data-woon-toast개별 토스트
data-tone개별 토스트"default" 또는 "danger"
data-state토스트 래퍼"open" 또는 "closed"
data-front토스트 래퍼가장 최신 토스트에 존재