diff --git a/packages/frontend/src/components/shared/Toast/ToastProvider.tsx b/packages/frontend/src/components/shared/Toast/ToastProvider.tsx
new file mode 100644
index 00000000..57ecf2b6
--- /dev/null
+++ b/packages/frontend/src/components/shared/Toast/ToastProvider.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import {
+ Provider,
+ Viewport,
+ type ToastProviderProps,
+} from '@radix-ui/react-toast';
+
+export const ToastProvider = ({ children, ...props }: ToastProviderProps) => {
+ return (
+
+ {children}
+
+
+ );
+};
diff --git a/packages/frontend/src/components/shared/Toast/Toaster.tsx b/packages/frontend/src/components/shared/Toast/Toaster.tsx
new file mode 100644
index 00000000..85222167
--- /dev/null
+++ b/packages/frontend/src/components/shared/Toast/Toaster.tsx
@@ -0,0 +1,25 @@
+import React, { ComponentPropsWithoutRef, useMemo } from 'react';
+import { Provider, Viewport } from '@radix-ui/react-toast';
+import { SimpleToast, SimpleToastProps } from './SimpleToast';
+import { useToast } from './useToast';
+
+interface ToasterProps extends ComponentPropsWithoutRef<'div'> {}
+
+export const Toaster = ({}: ToasterProps) => {
+ const { toasts } = useToast();
+
+ const renderToasts = useMemo(
+ () =>
+ toasts.map(({ id, ...props }) => (
+
+ )),
+ [toasts],
+ );
+
+ return (
+
+ {renderToasts}
+
+
+ );
+};
diff --git a/packages/frontend/src/components/shared/Toast/useToast.tsx b/packages/frontend/src/components/shared/Toast/useToast.tsx
new file mode 100644
index 00000000..ad088be4
--- /dev/null
+++ b/packages/frontend/src/components/shared/Toast/useToast.tsx
@@ -0,0 +1,190 @@
+// Inspired by react-hot-toast library
+import React from 'react';
+import { type ToastProps } from '@radix-ui/react-toast';
+
+const TOAST_LIMIT = 3;
+const TOAST_REMOVE_DELAY_DEFAULT = 7000;
+
+type ToasterToast = ToastProps & {
+ id: string;
+};
+
+const actionTypes = {
+ ADD_TOAST: 'ADD_TOAST',
+ UPDATE_TOAST: 'UPDATE_TOAST',
+ DISMISS_TOAST: 'DISMISS_TOAST',
+ REMOVE_TOAST: 'REMOVE_TOAST',
+} as const;
+
+let count = 0;
+
+const genId = () => {
+ count = (count + 1) % Number.MAX_VALUE;
+ return count.toString();
+};
+
+type ActionType = typeof actionTypes;
+
+type Action =
+ | {
+ type: ActionType['ADD_TOAST'];
+ toast: ToasterToast;
+ }
+ | {
+ type: ActionType['UPDATE_TOAST'];
+ toast: Partial;
+ }
+ | {
+ type: ActionType['DISMISS_TOAST'];
+ toastId?: ToasterToast['id'];
+ duration?: ToasterToast['duration'];
+ }
+ | {
+ type: ActionType['REMOVE_TOAST'];
+ toastId?: ToasterToast['id'];
+ };
+
+interface State {
+ toasts: ToasterToast[];
+}
+
+const toastTimeouts = new Map>();
+
+const addToRemoveQueue = (toastId: string, duration: number) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: 'REMOVE_TOAST',
+ toastId: toastId,
+ });
+ }, duration ?? TOAST_REMOVE_DELAY_DEFAULT);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case 'ADD_TOAST':
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ };
+
+ case 'UPDATE_TOAST':
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id
+ ? ({ ...t, ...action.toast } as ToasterToast)
+ : t,
+ ),
+ };
+
+ case 'DISMISS_TOAST': {
+ const { toastId, duration = TOAST_REMOVE_DELAY_DEFAULT } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId, duration);
+ } else {
+ state.toasts.forEach((_toast) => {
+ addToRemoveQueue(_toast.id, duration);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t,
+ ),
+ };
+ }
+ case 'REMOVE_TOAST':
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ };
+ }
+};
+
+const listeners: Array<(_state: State) => void> = [];
+
+let memoryState: State = { toasts: [] };
+
+const dispatch = (action: Action) => {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+};
+
+const toast = (props: ToasterToast) => {
+ if (!props.duration) {
+ props.duration = 20000;
+ }
+ const id = genId();
+
+ const update = (_props: ToasterToast) =>
+ dispatch({
+ type: 'UPDATE_TOAST',
+ toast: { ..._props, id },
+ });
+ const dismiss = () =>
+ dispatch({ type: 'DISMISS_TOAST', toastId: id, duration: props.duration });
+
+ dispatch({
+ type: 'ADD_TOAST',
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open: boolean) => {
+ if (!open) dismiss();
+ },
+ },
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ };
+};
+
+const useToast = () => {
+ const [state, setState] = React.useState(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
+ };
+};
+
+export { toast, useToast };