Toast

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
저장되었습니다
!변경사항이 있습니다
×전송 실패 — 다시 시도해 주세요

Toast

화면 하단에서 위로 올라오는 애니메이션이 적용된 토스트 팝업입니다.
첫 번째 토스트가 나타날 때 컨테이너가 생성되고, 이후에는 그 안에 순서대로 쌓입니다.
각 토스트는 duration(초) 이후 자동으로 사라지며, 지정하지 않으면 기본 3초 동안 유지됩니다.

기본 토스트 (3초 유지)

duration을 지정하지 않으면 기본 3초 동안 표시됩니다.

Source Code
          const [toasts, setToasts] = useState<ToastItem[]>([]);

          const pushToast = (message: string) => {
            setToasts(prev => [
              ...prev,
              { id: Date.now(), message },
            ]);
          };

          <Button onClick={() => pushToast('기본 토스트입니다.')}>토스트 띄우기</Button>
          <ToastStack toasts={toasts} onRemove={id => setToasts(prev => prev.filter(t => t.id !== id))} />
        
Live Preview

용도별 유지 시간 (duration)

duration(초)을 props로 넘겨 상황에 맞는 유지 시간을 가질 수 있습니다.

5 Seconds
pushToast('5초 동안 유지되는 토스트입니다.', { duration: 5 });
Live Preview
1 Second
pushToast('1초만 깜빡이는 토스트입니다.', { duration: 1 });
Live Preview

상황별 Variant 토스트

성공 / 에러 / 경고 / 정보 등 상황에 따라 색상과 아이콘이 달라지는 토스트입니다.

Success
pushToast('성공적으로 처리되었습니다.', { variant: 'success' });
Live Preview
Error
pushToast('오류가 발생했습니다.', { variant: 'error' });
Live Preview
Warning
pushToast('주의가 필요한 상태입니다.', { variant: 'warning' });
Live Preview
Info
pushToast('안내 메시지입니다.', { variant: 'info' });
Live Preview

클릭 가능한 토스트

토스트를 클릭했을 때 추가 동작(onClick)을 수행할 수 있습니다.

Source Code
          pushToast('클릭 가능한 토스트입니다.', {
            clickable: true,
          });
        
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Toasttypescript
'use client';

import { useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

export type ToastVariant = 'success' | 'error' | 'warning' | 'info' | 'default';

export interface ToastItem {
  id: number;
  message: ReactNode;
  duration?: number; // 초 단위
  onClick?: () => void;
  variant?: ToastVariant;
}

export interface ToastStackProps {
  toasts: ToastItem[];
  onRemove: (id: number) => void;
  defaultDurationSeconds?: number;
}

const DEFAULT_DURATION_SECONDS = 3;

interface ToastRowProps {
  item: ToastItem;
  index: number;
  defaultDurationSeconds: number;
  onRemove: (id: number) => void;
}

/**
 * Variant별 아이콘 원형 배경 색.
 * LIVE PREVIEW 스타일: slate-900 다크 패널 + 컬러 아이콘 원 + 흰 텍스트.
 */
const variantIconBg: Record<ToastVariant, string> = {
  default: 'bg-slate-500',
  info: 'bg-sky-500',
  success: 'bg-emerald-500',
  warning: 'bg-amber-500',
  error: 'bg-rose-500',
};

function ToastVariantIcon({ variant, className }: { variant: ToastVariant; className?: string }) {
  const c = cn('size-3.5 text-white', className);
  switch (variant) {
    case 'success':
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
        </svg>
      );
    case 'warning':
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01" />
        </svg>
      );
    case 'error':
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
        </svg>
      );
    case 'info':
    case 'default':
    default:
      return (
        <svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      );
  }
}

function ToastRow({
  item,
  index,
  defaultDurationSeconds,
  onRemove,
}: ToastRowProps) {
  const [leaving, setLeaving] = useState(false);
  const variant = item.variant ?? 'default';
  const iconBg = variantIconBg[variant];

  useEffect(() => {
    const seconds = item.duration ?? defaultDurationSeconds;
    const timer = setTimeout(() => setLeaving(true), seconds * 1000);
    return () => clearTimeout(timer);
  }, [item.duration, defaultDurationSeconds]);

  useEffect(() => {
    if (!leaving) return;
    const timer = setTimeout(() => onRemove(item.id), 200);
    return () => clearTimeout(timer);
  }, [leaving, item.id, onRemove]);

  return (
    <div
      className={cn(
        'sg-toast sg-toast-stack-item w-full min-w-[280px] flex items-center gap-3 rounded-xl bg-slate-900 px-4 py-3 shadow-xl shadow-slate-900/30 transition-all duration-200',
        index > 0 && 'sg-toast-stacked',
        leaving && 'sg-toast-leave',
        item.onClick && 'cursor-pointer hover:bg-slate-800 active:scale-[0.99]',
      )}
      role="status"
      onClick={item.onClick}
    >
      <span
        className={cn(
          'flex size-6 shrink-0 items-center justify-center rounded-full',
          iconBg,
        )}
      >
        <ToastVariantIcon variant={variant} />
      </span>
      <div className="min-w-0 flex-1 text-sm font-semibold leading-snug text-white">
        {typeof item.message === 'string' ? item.message : <>{item.message}</>}
      </div>
    </div>
  );
}

/**
 * 화면 하단에 쌓이는 토스트 스택 컴포넌트
 *
 * - Alert/Confirm과 동일한 variant·아이콘·색상 체계로 일관된 디자인
 * - 첫 번째 토스트가 나타날 때 아래에서 위로 올라오는 애니메이션
 * - 각 토스트는 duration(초) 이후 자동 제거
 */
export function ToastStack({
  toasts,
  onRemove,
  defaultDurationSeconds = DEFAULT_DURATION_SECONDS,
}: ToastStackProps) {
  if (!toasts.length) return null;

  return (
    <div className="fixed bottom-8 left-1/2 z-130 w-full max-w-[420px] -translate-x-1/2 px-4">
      <div className="sg-toast-stack flex w-full flex-col gap-3">
        {toasts.map((item, index) => (
          <ToastRow
            key={item.id}
            item={item}
            index={index}
            defaultDurationSeconds={defaultDurationSeconds}
            onRemove={onRemove}
          />
        ))}
      </div>
    </div>
  );
}