;
+
+export const Basic: Story = {
+ args: {
+ title: '세부 목표를 삭제했어요.',
+ type: 'success',
+ },
+};
+
+export const ClickToToast = () => {
+ const toast = useToast();
+
+ return ;
+};
diff --git a/src/components/atoms/toast/Toast.tsx b/src/components/atoms/toast/Toast.tsx
new file mode 100644
index 00000000..a3531b9d
--- /dev/null
+++ b/src/components/atoms/toast/Toast.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useSetAtom } from 'jotai';
+
+import SuccessIcon from '@/assets/icons/toast-success.svg';
+import WarningIcon from '@/assets/icons/toast-warning.svg';
+
+import { Typography } from '../typography';
+
+import { removeToastAtom } from './Toast.atom';
+
+export interface ToastProps {
+ id: number;
+ title: string;
+ type?: 'success' | 'warning';
+}
+
+const toastIcon = {
+ success: ,
+ warning: ,
+};
+
+const TOAST_DURATION = 3000;
+const ANIMATION_DURATION = 350;
+
+export const Toast = ({ id, title, type = 'success' }: ToastProps) => {
+ const [opacity, setOpacity] = useState('opacity-[0.2]');
+ const removeToastItem = useSetAtom(removeToastAtom);
+
+ useEffect(() => {
+ setOpacity('opacity-[0.8]');
+ const timeoutForRemove = setTimeout(() => {
+ removeToastItem(id);
+ }, TOAST_DURATION);
+
+ const timeoutForVisible = setTimeout(() => {
+ setOpacity('opacity-0');
+ }, TOAST_DURATION - ANIMATION_DURATION);
+
+ return () => {
+ clearTimeout(timeoutForRemove);
+ clearTimeout(timeoutForVisible);
+ };
+ }, [id, removeToastItem]);
+
+ return (
+
+ {toastIcon[type]}
+
+ {title}
+
+
+ );
+};
diff --git a/src/components/atoms/toast/ToastProvider.tsx b/src/components/atoms/toast/ToastProvider.tsx
new file mode 100644
index 00000000..4b59cec9
--- /dev/null
+++ b/src/components/atoms/toast/ToastProvider.tsx
@@ -0,0 +1,20 @@
+import * as Portal from '@radix-ui/react-portal';
+import { useAtomValue } from 'jotai';
+
+import { Toast } from './Toast';
+import { toastOptionAtom, toastsAtom } from './Toast.atom';
+
+export const ToastProvider = () => {
+ const toasts = useAtomValue(toastsAtom);
+ const { position } = useAtomValue(toastOptionAtom);
+
+ return (
+
+
+ {toasts.map((toast) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts
new file mode 100644
index 00000000..7f61beed
--- /dev/null
+++ b/src/hooks/useToast.ts
@@ -0,0 +1,19 @@
+import { useEffect } from 'react';
+import { useSetAtom } from 'jotai';
+
+import type { ToastOptionProps } from '@/components/atoms/toast/Toast.atom';
+import { toastAtom, toastOptionChangeAtom } from '@/components/atoms/toast/Toast.atom';
+
+export const useToast = (option?: ToastOptionProps) => {
+ const addToast = useSetAtom(toastAtom);
+ const setOption = useSetAtom(toastOptionChangeAtom);
+
+ useEffect(() => {
+ if (option) setOption(option);
+ }, [option, setOption]);
+
+ return {
+ success: addToast('success'),
+ warning: addToast('warning'),
+ };
+};
diff --git a/yarn.lock b/yarn.lock
index f8629693..672f9174 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3109,6 +3109,26 @@ __metadata:
languageName: node
linkType: hard
+"@radix-ui/react-portal@npm:^1.0.4":
+ version: 1.0.4
+ resolution: "@radix-ui/react-portal@npm:1.0.4"
+ dependencies:
+ "@babel/runtime": "npm:^7.13.10"
+ "@radix-ui/react-primitive": "npm:1.0.3"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: fed32f8148b833fe852fb5e2f859979ffdf2fb9a9ef46583b9b52915d764ad36ba5c958a64e61d23395628ccc09d678229ee94cd112941e8fe2575021f820c29
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-presence@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-presence@npm:1.0.1"
@@ -6420,6 +6440,7 @@ __metadata:
dependencies:
"@hookform/devtools": "npm:^4.3.1"
"@radix-ui/react-icons": "npm:^1.3.0"
+ "@radix-ui/react-portal": "npm:^1.0.4"
"@radix-ui/react-tabs": "npm:^1.0.4"
"@sentry/nextjs": "npm:^7.91.0"
"@sentry/utils": "npm:^7.91.0"