React Toast 스택 컴포넌트 설계 — variant·자동 제거·leaving 애니메이션, Alert과 한 체계

#Alert#Context#Next.js#React#role="status"

'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;
}

/** Alert 모달과 동일한 variant 컬러아이콘 체계 */
const variantConfig: Record
  ToastVariant,
  { borderAccent: string; iconBg: string; iconColor: string; messageColor: string }
 = {
  default: {
    borderAccent: 'border-l-4 border-slate-400',
    iconBg: 'bg-slate-100',
    iconColor: 'text-slate-600',
    messageColor: 'text-slate-800',
  },
  info: {
    borderAccent: 'border-l-4 border-blue-500',
    iconBg: 'bg-blue-100',
    iconColor: 'text-blue-600',
    messageColor: 'text-slate-800',
  },
  success: {
    borderAccent: 'border-l-4 border-emerald-500',
    iconBg: 'bg-emerald-100',
    iconColor: 'text-emerald-600',
    messageColor: 'text-slate-800',
  },
  warning: {
    borderAccent: 'border-l-4 border-amber-500',
    iconBg: 'bg-amber-100',
    iconColor: 'text-amber-600',
    messageColor: 'text-slate-800',
  },
  error: {
    borderAccent: 'border-l-4 border-red-500',
    iconBg: 'bg-red-100',
    iconColor: 'text-red-600',
    messageColor: 'text-slate-800',
  },
};

function ToastVariantIcon({ variant, className }: { variant: ToastVariant; className?: string }) {
  const c = cn('size-5', className);
  switch (variant) {
    case 'success':
      return (
        svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
          path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /
        /svg
      );
    case 'warning':
      return (
        svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
          path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /
        /svg
      );
    case 'error':
      return (
        svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
          path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /
        /svg
      );
    case 'info':
    case 'default':
    default:
      return (
        svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
          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 config = variantConfig[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 min-w-[280px] w-full flex items-center gap-3 rounded-xl border border-slate-200/80 bg-white px-4 py-3.5 shadow-lg ring-1 ring-black/5 transition-all duration-200',
        config.borderAccent,
        index  0 && 'sg-toast-stacked',
        leaving && 'sg-toast-leave',
        item.onClick && 'cursor-pointer hover:shadow-xl active:scale-[0.99]',
      )}
      role="status"
      onClick={item.onClick}
    
      span
        className={cn(
          'flex size-10 shrink-0 items-center justify-center rounded-lg',
          config.iconBg,
          config.iconColor,
        )}
      
        ToastVariantIcon variant={variant} /
      /span
      div className={cn('min-w-0 flex-1 text-sm font-medium leading-snug', config.messageColor)}
        {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-50 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
  );
}


1. 왜 Toast를 따로 만들었는지

저장 완료삭제 완료실패 안내처럼 잠깐 보여주고 사라지는 메시지가 관리자마이페이지연락처 등 전역에서 필요했는데, 매번 위치스타일자동 닫힘을 따로 구현하기 부담됐습니다. AlertConfirm과 같은 variant(성공/에러/경고/정보)와 색아이콘을 맞추고, 여러 개가 동시에 뜰 수 있게 스택으로 쌓이게 하려고 공용 Toast를 만들기로 했습니다. 표시만 담당하는 ToastStack과, 상태showToast API를 제공하는 AlertContext/AlertProvider를 나눠서, UI는 Toast, 호출은 context에서 하게 했습니다.

2. 컴포넌트 구성과 설계 방향

역할 분리
Toast.tsx는 toasts 배열을 받아서 그리기만 합니다. 추가삭제는 부모(AlertProvider)가 toasts state와 onRemove로 처리하고, showToast는 context에서 id를 생성해 toasts에 push한 뒤, 각 토스트가 duration 뒤에 onRemove(id)를 호출해 스택에서 제거되게 했습니다. 이렇게 해서 Toast 컴포넌트는 presentational만 담당하고, 전역 API는 Provider 쪽에만 두었습니다.

ToastRow와 자동 제거
각 항목은 ToastRow에서 렌더링합니다. 마운트 시 item.duration ?? defaultDurationSeconds(초)만큼 타이머를 두고, 끝나면 setLeaving(true)로 전환합니다. leaving이 true가 되면 200ms 뒤에 onRemove(item.id)를 호출해, 사라지는 애니메이션 DOM에서 제거 순서가 되게 했습니다. 그래서 duration만 지나면 자동으로 스택에서 빠지고, 닫힐 때 짧게 퇴장 효과를 줄 수 있습니다.

variant아이콘
Alert 모달과 같은 의미 체계를 쓰려고 variantConfig에 success/error/warning/info/default별로 borderAccenticonBgiconColormessageColor를 두었고, ToastVariantIcon에서 variant마다 체크경고 삼각형X정보 원 아이콘을 매핑했습니다. 그래서 성공은 초록, 에러는 빨강이 Alert과 Toast에서 동일하게 보입니다.

접근성
각 토스트 컨테이너에 role="status"를 줘서 보조 기술이 상태 메시지로 인식하게 했습니다. 클릭 가능한 토스트는 onClick을 받아서, 필요하면 클릭 시 상세로 이동 같은 동작을 붙일 수 있게 했습니다.

위치
화면 하단 중앙에 고정해서, 모달헤더에 가리지 않고 자연스럽게 보이게 했습니다. fixed bottom-8 left-1/2 -translate-x-1/2max-w-[420px]로 너비를 제한하고, 여러 개일 때는 flex flex-col gap-3으로 세로로 쌓이게 했습니다. sg-toastsg-toast-stack-itemsg-toast-leaved 같은 클래스는 나중에 CSS로 등장/퇴장 애니메이션을 넣을 수 있게 남겨 둔 것입니다.

3. 프롭스타입이 필요한 이유

ToastItem (id, message, duration, onClick, variant)
id는 스택에서 어떤 항목을 제거할지 구분하려고 넣었습니다. Provider가 toastIdRef로 증가시키며 부여합니다. message는 ReactNode라서 문자열뿐 아니라 강조링크가 들어간 JSX도 넣을 수 있게 했습니다. duration은 몇 초 뒤 자동 제거용이고, 초 단위로 두어서 ToastRow의 setTimeout과 맞췄습니다. (AlertProvider에서는 ToastOptions의 duration이 ms라서 1000으로 나눠 넣습니다.) onClick은 토스트를 클릭했을 때 추가 동작(상세 페이지 이동 등)을 넣으려고 선택적으로 두었고, 있으면 cursor-pointer와 호버 스타일을 줍니다. variant는 success/error/warning/info/default로, 색아이콘을 결정합니다.

ToastStackProps (toasts, onRemove, defaultDurationSeconds)
toasts는 표시할 항목 배열입니다. Provider가 state로 관리하고, showToast 시마다 push합니다. onRemove는 각 토스트가 duration 경과 또는 퇴장 애니메이션 후 호출하는 콜백으로, Provider에서는 해당 id를 toasts에서 제거하는 setState를 넘깁니다. defaultDurationSeconds는 개별 item에 duration이 없을 때 쓰는 기본값(기본 3초)이라서, 대부분 3초, 에러만 4초처럼 쓸 수 있게 했습니다.

4. 사용 용도와 쓰는 방식

  • 관리자 CRUD: 카테고리메뉴푸터 링크소셜미디어블로그 글약관 등에서 저장삭제수정 완료 시 showToast({ message: '...', variant: 'success' }), 실패나 부분 실패 시 variant: 'error' 또는 variant: 'warning'으로 호출합니다. API 에러 메시지는 applyApiError 등으로 처리한 뒤 실패 시에만 toast를 띄우는 식으로 씁니다.

  • 마이페이지세션비밀번호: 로그아웃비밀번호 변경세션 해제 성공은 success, 세션 만료에러는 error로 toast를 띄우고, 중요도에 따라 duration: 4000처럼 조금 더 길게 둡니다.

  • 연락처: 문의 접수 성공/실패를 toast(...) 또는 context의 showToast로 알립니다.

  • 쇼케이스: toasts state와 pushToastonRemove를 로컬로 두고 ToastStack toasts={toasts} onRemove={...}만 넘겨서 variant자동 제거스택 동작을 시연합니다.

5. 정리

Toast는 Alert과 같은 variant아이콘 체계로, 하단 중앙에 스택으로 쌓이고, duration 뒤 자동 제거와 짧은 퇴장 애니메이션까지 한 번에 처리하자는 목적으로 만들었습니다. ToastItem의 idmessagedurationonClickvariant는 스택 관리표시자동 제거클릭시각 구분을 위해 두었고, ToastStack의 toastsonRemovedefaultDurationSeconds는 표시할 목록과 제거 시 콜백기본 지속 시간을 위해 넣었습니다. 실제 노출은 AlertProvider가 ToastStack을 렌더하고, useAlertContext().showToast로 전역에서 호출하는 구조로 쓰고 있습니다.


49

댓글