diff --git a/FE/src/App.tsx b/FE/src/App.tsx
index ca07e6c..443aba9 100644
--- a/FE/src/App.tsx
+++ b/FE/src/App.tsx
@@ -7,6 +7,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import GlobalSVGProvider from '@/GlobalSvgProvider';
import AppRouter from '@/AppRouter';
import ModalProvider from './modalProvider';
+import { ToastContainer } from '@foundation/Toast/ToastContainer';
function App() {
const queryClient = new QueryClient();
@@ -20,6 +21,7 @@ function App() {
+
diff --git a/FE/src/GlobalSvgProvider.tsx b/FE/src/GlobalSvgProvider.tsx
index 6aa1f86..8e2902b 100644
--- a/FE/src/GlobalSvgProvider.tsx
+++ b/FE/src/GlobalSvgProvider.tsx
@@ -322,6 +322,30 @@ const spliteSvgCode = (
strokeLinejoin="round"
/>
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/FE/src/components/foundation/Toast/Toast.styles.ts b/FE/src/components/foundation/Toast/Toast.styles.ts
new file mode 100644
index 0000000..6e2fcc9
--- /dev/null
+++ b/FE/src/components/foundation/Toast/Toast.styles.ts
@@ -0,0 +1,80 @@
+import { css, keyframes } from '@emotion/react';
+import { theme } from '@styles/theme';
+
+const TOAST_GAP = '0.75rem';
+export const ToastProgressBarStyle = {
+ info: css`
+ background-color: ${theme.colors.toast.info};
+ `,
+ success: css`
+ background-color: ${theme.colors.toast.success};
+ `,
+ warning: css`
+ background-color: ${theme.colors.toast.warning};
+ `,
+ error: css`
+ background-color: ${theme.colors.toast.error};
+ `,
+ default: css`
+ background-color: ${theme.colors.toast.default};
+ `,
+};
+
+export const ToastTypeIconName = {
+ info: 'info-round',
+ success: 'check-round',
+ warning: 'warning',
+ error: 'bomb',
+ default: null,
+};
+
+export const ToastPositionStyle = {
+ topLeft: css`
+ top: ${TOAST_GAP};
+ left: ${TOAST_GAP};
+ `,
+ topRight: css`
+ top: ${TOAST_GAP};
+ right: ${TOAST_GAP};
+ `,
+ topCenter: css`
+ top: ${TOAST_GAP};
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10000;
+ `,
+ bottomLeft: css`
+ bottom: ${TOAST_GAP};
+ left: ${TOAST_GAP};
+ `,
+ bottomRight: css`
+ bottom: ${TOAST_GAP};
+ right: ${TOAST_GAP};
+ `,
+ bottomCenter: css`
+ bottom: ${TOAST_GAP};
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 10000;
+ `,
+};
+
+export const ToastFadeOutUpAnimation = keyframes`
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(-1.5rem);
+ }
+`;
+
+export const ToastProgressBarAnimation = keyframes`
+ from {
+ transform: scaleX(1);
+ }
+ to {
+ transform: scaleX(0);
+ }
+`;
diff --git a/FE/src/components/foundation/Toast/ToastContainer.tsx b/FE/src/components/foundation/Toast/ToastContainer.tsx
new file mode 100644
index 0000000..54714b8
--- /dev/null
+++ b/FE/src/components/foundation/Toast/ToastContainer.tsx
@@ -0,0 +1,29 @@
+import { css } from '@emotion/react';
+import { ToastPositionStyle } from '@foundation/Toast/Toast.styles';
+import useToastContainer from '@foundation/Toast/useToastContainer';
+import ToastItem from '@foundation/Toast/ToastItem';
+
+export const ToastContainer = () => {
+ const { getToastPositionGroupToRender } = useToastContainer();
+ const positionGroup = getToastPositionGroupToRender();
+
+ return Array.from(positionGroup).map(([position, toasts]) => (
+
+ {toasts.map((toastProps) => (
+
+ ))}
+
+ ));
+};
diff --git a/FE/src/components/foundation/Toast/ToastItem.tsx b/FE/src/components/foundation/Toast/ToastItem.tsx
new file mode 100644
index 0000000..4e9f832
--- /dev/null
+++ b/FE/src/components/foundation/Toast/ToastItem.tsx
@@ -0,0 +1,117 @@
+import { useRef, useState } from 'react';
+import { ToastEvent, ToastProps } from '@foundation/Toast/type';
+import { css } from '@emotion/react';
+import { theme } from '@styles/theme';
+import { Box, Icon } from '@foundation/index';
+import { collapseToast } from '@foundation/Toast/collapseToast';
+import { eventManager } from '@foundation/Toast/eventManger';
+import {
+ ToastFadeOutUpAnimation,
+ ToastProgressBarAnimation,
+ ToastProgressBarStyle,
+ ToastTypeIconName,
+} from '@foundation/Toast/Toast.styles';
+
+const ToastItem: React.FC = ({
+ toastId,
+ text,
+ autoClose = 3000,
+ closeOnClick = true,
+ type = 'default',
+ pauseOnHover = true,
+}) => {
+ const toastRef = useRef(null);
+ const [isExiting, setIsExiting] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+
+ const handleExitingAnimationEnd = () => {
+ collapseToast(toastRef.current!, () => {
+ eventManager.emit(ToastEvent.Delete, toastId);
+ });
+ };
+
+ const handleProgressAnimationEnd = () => {
+ autoClose && setIsExiting(true);
+ };
+
+ const handleClick = () => {
+ closeOnClick && handleProgressAnimationEnd();
+ };
+
+ const handleMouseEnter = () => {
+ pauseOnHover && autoClose && setIsPaused(true);
+ };
+
+ const handleMouseLeave = () => {
+ if (pauseOnHover && autoClose) {
+ setIsPaused(false);
+ }
+ };
+
+ const IconType = () =>
+ type === 'default' ? null : (
+
+ );
+
+ return (
+
+ );
+};
+
+export default ToastItem;
diff --git a/FE/src/components/foundation/Toast/collapseToast.ts b/FE/src/components/foundation/Toast/collapseToast.ts
new file mode 100644
index 0000000..c4ca689
--- /dev/null
+++ b/FE/src/components/foundation/Toast/collapseToast.ts
@@ -0,0 +1,17 @@
+export function collapseToast(
+ node: HTMLDivElement,
+ done: () => void,
+ duration = 1000
+) {
+ const { scrollHeight, style } = node;
+
+ requestAnimationFrame(() => {
+ style.height = scrollHeight + 'px';
+ style.transition = `all ${duration}ms`;
+
+ requestAnimationFrame(() => {
+ style.height = '0';
+ setTimeout(done, duration);
+ });
+ });
+}
diff --git a/FE/src/components/foundation/Toast/eventManger.ts b/FE/src/components/foundation/Toast/eventManger.ts
new file mode 100644
index 0000000..27e8fd2
--- /dev/null
+++ b/FE/src/components/foundation/Toast/eventManger.ts
@@ -0,0 +1,55 @@
+import { EventManager } from '@foundation/Toast/type';
+
+export const eventManager: EventManager = {
+ list: new Map(), // 이벤트 리스너 저장
+ emitQueue: new Map(), // 이벤트 지연을 위한 큐
+
+ // 새로운 이벤트 리스너 등록
+ on(event, callback) {
+ this.list.has(event)
+ ? this.list.get(event)!.push(callback)
+ : this.list.set(event, [callback]);
+
+ return this;
+ },
+
+ // 콜백이 있는경우 해당하는 리스너 제거, 없는 경우 이벤트에 대한 모든 리스너 제거
+ off(event, callback) {
+ if (callback) {
+ const cb = this.list.get(event)?.filter((cb) => cb !== callback);
+ cb && this.list.set(event, cb);
+ return this;
+ }
+ this.list.delete(event);
+
+ return this;
+ },
+
+ //대기중인 이벤트를 취소시킬 때 필요함
+ cancelEmit(event) {
+ const timers = this.emitQueue.get(event);
+ if (timers) {
+ timers.forEach(clearTimeout);
+ this.emitQueue.delete(event);
+ }
+
+ return this;
+ },
+
+ // 이벤트 발생시키기
+ // 타입문제는 일단 보류중! (라이브러리 내부도 해결 안되어있음)
+ emit(event, ...args: never[]) {
+ this.list.has(event) &&
+ this.list.get(event)!.forEach((callback) => {
+ const timer = setTimeout(() => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ return callback(...args);
+ }, 0);
+
+ this.emitQueue.has(event)
+ ? this.emitQueue.get(event)!.push(timer)
+ : this.emitQueue.set(event, [timer]);
+ });
+ },
+};
diff --git a/FE/src/components/foundation/Toast/toast.ts b/FE/src/components/foundation/Toast/toast.ts
new file mode 100644
index 0000000..8672b62
--- /dev/null
+++ b/FE/src/components/foundation/Toast/toast.ts
@@ -0,0 +1,30 @@
+import { ToastEvent, ToastProps, ToastType } from '@foundation/Toast/type';
+import { eventManager } from '@foundation/Toast/eventManger';
+
+type ToastFunctionProps = Omit;
+type ToastOptions = Omit;
+const generateUniqueId = () => {
+ return Date.now().toString(36) + Math.random().toString(36).substring(4);
+};
+
+const emitToast = (type: ToastType, toastProps: ToastFunctionProps) => {
+ const id = generateUniqueId();
+ eventManager.emit(ToastEvent.Add, {
+ ...toastProps,
+ toastId: id,
+ type,
+ });
+};
+
+export const toast = {
+ default: (text: string, toastOptions?: ToastOptions) =>
+ emitToast('default', { text: text, ...toastOptions }),
+ info: (text: string, toastOptions?: ToastOptions) =>
+ emitToast('info', { text: text, ...toastOptions }),
+ success: (text: string, toastOptions?: ToastOptions) =>
+ emitToast('success', { text: text, ...toastOptions }),
+ warning: (text: string, toastOptions?: ToastOptions) =>
+ emitToast('warning', { text: text, ...toastOptions }),
+ error: (text: string, toastOptions?: ToastOptions) =>
+ emitToast('error', { text: text, ...toastOptions }),
+};
diff --git a/FE/src/components/foundation/Toast/type.ts b/FE/src/components/foundation/Toast/type.ts
new file mode 100644
index 0000000..85cfe80
--- /dev/null
+++ b/FE/src/components/foundation/Toast/type.ts
@@ -0,0 +1,43 @@
+import {
+ ToastPositionStyle,
+ ToastProgressBarStyle,
+} from '@foundation/Toast/Toast.styles';
+
+export type ToastPosition = keyof typeof ToastPositionStyle;
+export type ToastType = keyof typeof ToastProgressBarStyle;
+export type ToastProps = {
+ toastId: string;
+ text: string;
+ autoClose?: false | number;
+ closeOnClick?: boolean;
+ type?: ToastType;
+ pauseOnHover?: boolean;
+ position?: ToastPosition;
+};
+
+export const enum ToastEvent {
+ Add,
+ Delete,
+}
+
+type OnAddCallback = (props: ToastProps) => void;
+type OnDeleteCallback = (id: string) => void;
+
+export type Callback = OnAddCallback | OnDeleteCallback;
+
+type TimeoutId = ReturnType;
+
+export interface EventManager {
+ list: Map;
+ emitQueue: Map;
+
+ on(event: ToastEvent.Add, callback: OnAddCallback): EventManager;
+ on(event: ToastEvent.Delete, callback: OnDeleteCallback): EventManager;
+
+ off(event: ToastEvent.Add, callback: OnAddCallback): EventManager;
+ off(event: ToastEvent.Delete, callback: OnDeleteCallback): EventManager;
+
+ cancelEmit(event: ToastEvent): EventManager;
+ emit(event: ToastEvent.Add, props: ToastProps): void;
+ emit(event: ToastEvent.Delete, id: string): void;
+}
diff --git a/FE/src/components/foundation/Toast/useToastContainer.ts b/FE/src/components/foundation/Toast/useToastContainer.ts
new file mode 100644
index 0000000..9c93173
--- /dev/null
+++ b/FE/src/components/foundation/Toast/useToastContainer.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react';
+import { ToastEvent, ToastPosition, ToastProps } from '@foundation/Toast/type';
+import { eventManager } from '@foundation/Toast/eventManger';
+
+const useToastContainer = () => {
+ const [toastList, setToastList] = useState(new Map());
+
+ // 토스트 추가
+ const addToast = (props: ToastProps) => {
+ setToastList((prev) => new Map(prev).set(props.toastId, props));
+ };
+
+ // 토스트 삭제
+ const deleteToast = (id: string) => {
+ setToastList((prev) => {
+ const newMap = new Map(prev);
+ newMap.delete(id);
+ return newMap;
+ });
+ };
+
+ useEffect(() => {
+ eventManager.on(ToastEvent.Add, addToast);
+ eventManager.on(ToastEvent.Delete, deleteToast);
+
+ // 컴포넌트 언마운트 시 리스너 해제
+ return () => {
+ eventManager.off(ToastEvent.Add, addToast);
+ eventManager.off(ToastEvent.Delete, deleteToast);
+ };
+ }, []);
+
+ const toastListToArray = () => {
+ return Array.from(toastList);
+ };
+
+ const getToastPositionGroupToRender = () => {
+ const list = toastListToArray();
+ const positionGroup = new Map();
+ list.forEach(([_, toastProps]) => {
+ const position = toastProps.position || 'topRight';
+ positionGroup.has(position)
+ ? positionGroup.get(position)!.push(toastProps)
+ : positionGroup.set(position, [toastProps]);
+ });
+ return positionGroup;
+ };
+
+ return { getToastPositionGroupToRender };
+};
+
+export default useToastContainer;
diff --git a/FE/src/styles/_colors.ts b/FE/src/styles/_colors.ts
index d097515..6cbab46 100644
--- a/FE/src/styles/_colors.ts
+++ b/FE/src/styles/_colors.ts
@@ -56,6 +56,17 @@ const colorChips = {
red700: '#98382C',
red800: '#742B22',
red900: '#511E17',
+
+ yellow50: '#F9EFCB',
+ yellow100: '#F7EABC',
+ yellow200: '#F4E19E',
+ yellow300: '#F1D881',
+ yellow400: '#EDCF63',
+ yellow500: '#EAC645',
+ yellow600: '#C5A63A',
+ yellow700: '#9F872F',
+ yellow800: '#7A6724',
+ yellow900: '#544719',
} as const;
export const colors = {
@@ -103,4 +114,11 @@ export const colors = {
backdrop: {
default: colorChips.shadow400,
},
+ toast: {
+ info: colorChips.blue500,
+ success: colorChips.green500,
+ warning: colorChips.yellow500,
+ error: colorChips.red500,
+ default: colorChips.blue500,
+ },
};