diff --git a/src/components/Snackbar/Snackbar.mdx b/src/components/Snackbar/Snackbar.mdx
new file mode 100644
index 0000000..0d9ddfd
--- /dev/null
+++ b/src/components/Snackbar/Snackbar.mdx
@@ -0,0 +1,106 @@
+import { Canvas, Meta, Controls } from '@storybook/blocks';
+import * as SnackbarStories from './Snackbar.stories.tsx';
+import { Snackbar } from './Snackbar';
+
+
+
+# Snackbar
+
+UI의 최하단에서 유저의 이용을 방해하지 않으면서 유저가 수행했거나 수행해야 할 작업에 대해 일시적으로 피드백을 제공합니다.
+
+
+
+
+
+
+
+## 사용법
+
+Snackbar 컴포넌트의 기본 사용법입니다.
+
+1. Snackbar를 노출할 영역을 `SnackbarProvider`로 감싸줍니다.
+
+```tsx
+
+
+
+
+
+```
+
+2. `useSnackbar` 훅을 사용하여 snackbar를 가져옵니다.
+
+```tsx
+const { snackbar } = useSnackbar();
+```
+
+3. Snackbar를 호출하는 함수를 만들어서 Snackbar를 노출합니다.
+
+필수 프로퍼티인 `message`를 꼭 설정해주세요.
+
+이외의 프로퍼티들은 하단의 예시에서 확인할 수 있습니다.
+
+```tsx
+function App() {
+ const { snackbar } = useSnackbar();
+
+ const handleShowSnackbar = () => {
+ snackbar({
+ type: 'info', // Snackbar의 종류
+ width: '350px', // Snackbar의 가로 길
+ margin: '16px', // 왼쪽 오른쪽의 margin 값
+ message: '테스트용 스낵바입니다.', // Snackbar의 내용
+ duration: 3000, // Snackbar가 자동으로 닫히기 전까지의 시간(ms)
+ position: 'center', // Snackbar의 위치
+ });
+ };
+ return (
+ <>
+
+ Show Snackbar
+
+ >
+ );
+}
+```
+
+4. Snackbar를 닫을 시, info 타입일 때는 `드래그`, error 타입일 때는 `X 버튼`을 클릭하여 Snackbar를 닫을 수 있습니다.
+
+
+
+5. Snackbar는 `최대 두 줄까지` 입력되며, 두 줄을 넘어설 시, `ellipsis` 처리됩니다.
+
+
+
+## 예시
+
+### type
+
+`type` prop으로 Snackbar의 종류를 설정합니다. (info 또는 error)
+기본 값은 `info`입니다.
+
+### width
+
+`width` prop으로 원하는 Snackbar의 가로 길이를 설정합니다. (full-width, px, rem, em, %, vh, calc())
+기본 값으로 글자 길이에 맞게 가로 길이가 정해집니다.
+
+`full-width`인 경우, 기본적으로 `양쪽 margin 16px`이 설정됩니다. 이 때, position 설정은 적용되지 않습니다.
+
+### margin
+
+`margin` prop으로 Snackbar의 왼쪽 오른쪽의 margin 값을 정해줍니다.
+기본 값은 `16px`입니다.
+
+### message
+
+Snackbar의 필수 프로퍼티로, Snackbar의 내용을 설정합니다.
+
+### duration
+
+`duration` prop으로 Snackbar가 자동으로 닫히기까지의 시간을 설정합니다. (단위: `ms`)
+기본 값은 `5000`입니다.
+
+### position
+
+`position` prop으로 Snackbar의 위치를 설정합니다. (left, center, right)
+기본 값은 `center`입니다.
diff --git a/src/components/Snackbar/Snackbar.stories.tsx b/src/components/Snackbar/Snackbar.stories.tsx
new file mode 100644
index 0000000..aa79c62
--- /dev/null
+++ b/src/components/Snackbar/Snackbar.stories.tsx
@@ -0,0 +1,116 @@
+import { Meta, StoryObj } from '@storybook/react';
+
+import { BoxButton } from '@/components/BoxButton';
+
+import { Snackbar } from './Snackbar';
+import { SnackbarProps } from './Snackbar.type';
+import { SnackbarProvider } from './SnackbarProvider';
+import { useSnackbar } from './hooks/useSnackbar';
+
+const meta: Meta = {
+ title: 'Components/Snackbar',
+ component: Snackbar,
+ args: {
+ type: 'info',
+ duration: 5000,
+ margin: '16px',
+ position: 'center',
+ },
+ argTypes: {
+ type: {
+ description: 'Snackbar의 종류 (info 또는 error)',
+ control: { type: 'radio', options: ['info', 'error'] },
+ },
+ width: {
+ control: 'text',
+ description: `Snackbar의 가로 길이 (px, rem, em, %, vh, calc())`,
+ },
+ duration: {
+ description: 'Snackbar가 자동으로 닫히기 전까지의 시간 (ms)',
+ control: 'number',
+ },
+ position: {
+ description: 'Snackbar의 위치 (left, center, right)',
+ control: { type: 'radio', options: ['left', 'center', 'right'] },
+ },
+ message: {
+ control: 'text',
+ description: 'Snackbar의 내용 (메시지)',
+ },
+ onClose: { table: { disable: true } },
+ isClosing: { table: { disable: true } },
+ heightType: { table: { disable: true } },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const SnackbarComponent = (args: SnackbarProps) => {
+ const { snackbar } = useSnackbar();
+
+ const addSnackbar = () => {
+ snackbar({
+ ...args,
+ message: args.message || '기본 메시지입니다.',
+ });
+ };
+
+ const buttonLabel = args.type === 'error' ? 'Error Snackbar' : 'Info Snackbar';
+
+ return (
+
+
+ {buttonLabel}
+
+
+ );
+};
+
+export const Test: Story = {
+ render: (args) => (
+
+
+
+ ),
+ args: {
+ type: 'info',
+ position: 'center',
+ width: '350px',
+ message: '테스트용 스낵바입니다.',
+ },
+};
+
+export const Type: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ ),
+ args: {
+ width: '350px',
+ position: 'center',
+ },
+};
+
+export const OverflowTest: Story = {
+ render: (args) => (
+
+ 두 줄 이상 입력 시
+
+
+
+
+
+
+ ),
+ args: {
+ message:
+ '최대 2줄 입력 가능합니다. 입력 값이 넘칠 시, ellipsis 처리됩니다. 최대 2줄 입력 가능합니다. 입력 값이 넘칠 시, ellipsis 처리됩니다.',
+ width: '350px',
+ position: 'center',
+ },
+};
diff --git a/src/components/Snackbar/Snackbar.style.ts b/src/components/Snackbar/Snackbar.style.ts
new file mode 100644
index 0000000..6fc17ea
--- /dev/null
+++ b/src/components/Snackbar/Snackbar.style.ts
@@ -0,0 +1,130 @@
+import styled, { css } from 'styled-components';
+import { DefaultTheme } from 'styled-components/dist/types';
+import { match } from 'ts-pattern';
+
+import { SnackbarHeightType, SnackbarPosition, SnackbarProps, SnackbarType } from './Snackbar.type';
+
+interface StyledSnackbarProps {
+ $type: 'info' | 'error';
+ $width: SnackbarProps['width'];
+ $margin?: SnackbarProps['margin'];
+ $isClosing?: boolean;
+ $position: SnackbarPosition;
+ $heightType?: SnackbarHeightType;
+}
+
+const getBackgroundStyle = (arg: { $type: SnackbarType; theme: DefaultTheme }) => {
+ return match(arg)
+ .with({ $type: 'error' }, ({ theme }) => theme.semantic.color.snackbarError)
+ .otherwise(({ theme }) => theme.semantic.color.snackbarInfo);
+};
+
+const getFontColorStyle = (arg: { $type: SnackbarType; theme: DefaultTheme }) => {
+ return match(arg)
+ .with({ $type: 'error' }, ({ theme }) => theme.semantic.color.textStatusNegative)
+ .otherwise(() => arg.theme.semantic.color.textBasicWhite);
+};
+
+const getPositionStyle = (position: SnackbarPosition, margin?: string) => {
+ return match(position)
+ .with(
+ 'left',
+ () => css`
+ left: 0;
+ align-items: start;
+ margin-left: ${margin || '16px'};
+ `
+ )
+ .with(
+ 'right',
+ () => css`
+ right: 0;
+ align-items: end;
+ margin-right: ${margin || '16px'};
+ `
+ )
+ .otherwise(
+ () => css`
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+ align-items: center;
+ `
+ );
+};
+
+export const StyledSnackbarContainer = styled.div>`
+ position: fixed;
+ bottom: 0;
+ width: ${({ $width }) => ($width === 'full-width' ? '100%' : $width)};
+ height: fit-content;
+ display: flex;
+ flex-direction: column-reverse;
+ margin: 0 auto;
+ align-items: center;
+ ${({ $width, $position, $margin }) =>
+ $width !== 'full-width' && getPositionStyle($position, $margin)}
+`;
+
+export const StyledSnackbar = styled.div.withConfig({
+ shouldForwardProp: (prop) => prop !== 'isClosing',
+})`
+ position: relative;
+ padding: 16px;
+ margin-bottom: 16px;
+ width: ${({ $width }) => ($width === 'full-width' ? 'calc(100% - 32px)' : $width)};
+ height: ${({ $heightType }) => ($heightType === 2 ? '72px' : '52px')};
+ border-radius: ${({ theme }) => theme.semantic.radius.m}px;
+
+ ${({ $type, theme }) => ($type === 'info' ? theme.typo.B3_Rg_14 : theme.typo.B3_Sb_14)}
+ color: ${({ $type, theme }) => getFontColorStyle({ $type, theme })};
+ background-color: ${({ $type, theme }) => `${getBackgroundStyle({ $type, theme })}`};
+
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ justify-content: space-between;
+
+ ${({ $isClosing }) => css`
+ opacity: ${$isClosing ? 0 : 1};
+ transform: ${$isClosing ? 'translateY(100%)' : 'translateY(0)'};
+ transition:
+ opacity 300ms ease-out,
+ transform 300ms ease-out;
+ animation: ${$isClosing ? 'none' : 'slideIn 500ms ease-out'};
+ `}
+
+ @keyframes slideIn {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+`;
+
+export const StyledIcMessage = styled.div`
+ width: 100%;
+ justify-content: space-between;
+ align-items: flex-start;
+ display: flex;
+ gap: 8px;
+`;
+
+export const StyledMessage = styled.span`
+ width: 100%;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+export const StyledErrorIc = styled.div`
+ height: 20px;
+ cursor: pointer;
+ color: ${({ theme }) => theme.semantic.color.iconBasicTertiary};
+`;
diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx
new file mode 100644
index 0000000..788e957
--- /dev/null
+++ b/src/components/Snackbar/Snackbar.tsx
@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { IcAlertTriangleFilled, IcCloseFilled } from '@/style';
+
+import { StyledErrorIc, StyledIcMessage, StyledMessage, StyledSnackbar } from './Snackbar.style';
+import { SnackbarHeightType, SnackbarProps } from './Snackbar.type';
+import { useTouchMouseDrag } from './hooks/useMouseTouchDrag';
+
+export const Snackbar = ({
+ type = 'info',
+ width,
+ margin,
+ message,
+ onClose,
+ duration = 5000,
+ position = 'center',
+ isClosing: initialIsClosing,
+}: SnackbarProps) => {
+ const messageRef = useRef(null);
+ const [heightType, setHeightType] = useState(1);
+ const [isClosing, setIsClosing] = useState(initialIsClosing);
+
+ const closeToast = useCallback(() => {
+ if (!isClosing && onClose) {
+ setIsClosing(true);
+ setTimeout(() => onClose(), 300);
+ }
+ }, [isClosing, onClose]);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ closeToast();
+ }, duration);
+
+ return () => clearTimeout(timeout);
+ }, [duration, closeToast]);
+
+ useEffect(() => {
+ if (messageRef.current) {
+ const messageHeight = messageRef.current.clientHeight;
+ const lineHeight = parseInt(window.getComputedStyle(messageRef.current).lineHeight, 10);
+ const isMultiLine = messageHeight > lineHeight;
+ setHeightType(isMultiLine ? 2 : 1);
+ }
+ }, [message]);
+
+ const snackbarRef = useTouchMouseDrag(() => {
+ closeToast();
+ });
+
+ return (
+
+
+ {type === 'error' && }
+ {message}
+ {type === 'error' && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/Snackbar/Snackbar.type.ts b/src/components/Snackbar/Snackbar.type.ts
new file mode 100644
index 0000000..8df12ee
--- /dev/null
+++ b/src/components/Snackbar/Snackbar.type.ts
@@ -0,0 +1,25 @@
+export type SnackbarType = 'info' | 'error';
+export type SnackbarHeightType = 1 | 2;
+export type SnackbarPosition = 'left' | 'center' | 'right';
+export type SnackbarWidth =
+ | 'full-width'
+ | `${number}px`
+ | `${number}rem`
+ | `${number}em`
+ | `${number}%`
+ | `${number}vh`
+ | `calc(${string})`;
+
+export interface SnackbarProps {
+ type?: SnackbarType;
+ width?: SnackbarWidth;
+ margin?: string;
+ message: string;
+ onClose?: () => void;
+ duration?: number;
+ position?: SnackbarPosition;
+ isClosing: boolean;
+ heightType?: SnackbarHeightType;
+}
+
+export type SnackbarWithoutClosingProps = Omit;
diff --git a/src/components/Snackbar/SnackbarProvider.tsx b/src/components/Snackbar/SnackbarProvider.tsx
new file mode 100644
index 0000000..b3d1d45
--- /dev/null
+++ b/src/components/Snackbar/SnackbarProvider.tsx
@@ -0,0 +1,52 @@
+import { createContext, PropsWithChildren, useCallback, useContext, useState } from 'react';
+
+import ReactDOM from 'react-dom';
+
+import { Snackbar } from './Snackbar';
+import { StyledSnackbarContainer } from './Snackbar.style';
+import { SnackbarWithoutClosingProps } from './Snackbar.type';
+
+type SnackbarContextType = {
+ showSnackbar: (props: SnackbarWithoutClosingProps) => void;
+};
+
+const SnackbarContext = createContext({ showSnackbar: () => {} });
+
+export const SnackbarProvider = ({ children }: PropsWithChildren) => {
+ const [snackbar, setSnackbar] = useState(null);
+ const [isClosing, setIsClosing] = useState(false);
+
+ const showSnackbar = useCallback((props: SnackbarWithoutClosingProps) => {
+ setSnackbar(props);
+ setIsClosing(false);
+ }, []);
+
+ const removeSnackbar = useCallback(() => {
+ setIsClosing(true);
+ setTimeout(() => setSnackbar(null), 300);
+ }, []);
+
+ return (
+
+ {children}
+ {snackbar &&
+ ReactDOM.createPortal(
+
+
+ ,
+ document.body
+ )}
+
+ );
+};
+
+export const useSnackbarContext = () => {
+ const context = useContext(SnackbarContext);
+ if (!context) {
+ throw new Error('useSnackbar must be used within a SnackbarProvider');
+ }
+ return context;
+};
diff --git a/src/components/Snackbar/hooks/useMouseTouchDrag.ts b/src/components/Snackbar/hooks/useMouseTouchDrag.ts
new file mode 100644
index 0000000..491ba51
--- /dev/null
+++ b/src/components/Snackbar/hooks/useMouseTouchDrag.ts
@@ -0,0 +1,56 @@
+import { useState, useRef, useEffect } from 'react';
+
+export const useTouchMouseDrag = (onDismiss: () => void, threshold: number = 10) => {
+ const [startY, setStartY] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const elementRef = useRef(null);
+
+ const handleStart = (event: TouchEvent | MouseEvent) => {
+ const y = event instanceof TouchEvent ? event.touches[0].clientY : event.clientY;
+ setStartY(y);
+ setIsDragging(true);
+ };
+
+ const handleMove = (event: TouchEvent | MouseEvent) => {
+ if (!isDragging || startY === null) return;
+
+ const currentY = event instanceof TouchEvent ? event.touches[0].clientY : event.clientY;
+ const diffY = currentY - startY;
+
+ if (diffY > threshold) {
+ onDismiss();
+ setIsDragging(false);
+ setStartY(null);
+ }
+ };
+
+ const handleEnd = () => {
+ setIsDragging(false);
+ setStartY(null);
+ };
+
+ useEffect(() => {
+ const element = elementRef.current;
+ if (element) {
+ element.addEventListener('mousedown', handleStart);
+ window.addEventListener('mousemove', handleMove);
+ window.addEventListener('mouseup', handleEnd);
+
+ element.addEventListener('touchstart', handleStart);
+ element.addEventListener('touchmove', handleMove);
+ element.addEventListener('touchend', handleEnd);
+
+ return () => {
+ element.removeEventListener('mousedown', handleStart);
+ window.removeEventListener('mousemove', handleMove);
+ window.removeEventListener('mouseup', handleEnd);
+
+ element.removeEventListener('touchstart', handleStart);
+ element.removeEventListener('touchmove', handleMove);
+ element.removeEventListener('touchend', handleEnd);
+ };
+ }
+ }, [isDragging, startY]);
+
+ return elementRef;
+};
diff --git a/src/components/Snackbar/hooks/useSnackbar.tsx b/src/components/Snackbar/hooks/useSnackbar.tsx
new file mode 100644
index 0000000..d47e6b5
--- /dev/null
+++ b/src/components/Snackbar/hooks/useSnackbar.tsx
@@ -0,0 +1,12 @@
+import { SnackbarWithoutClosingProps } from '../Snackbar.type';
+import { useSnackbarContext } from '../SnackbarProvider';
+
+export const useSnackbar = () => {
+ const { showSnackbar } = useSnackbarContext();
+
+ const snackbar = (props: SnackbarWithoutClosingProps) => {
+ showSnackbar(props);
+ };
+
+ return { snackbar };
+};
diff --git a/src/components/Snackbar/index.ts b/src/components/Snackbar/index.ts
new file mode 100644
index 0000000..396ee3f
--- /dev/null
+++ b/src/components/Snackbar/index.ts
@@ -0,0 +1,3 @@
+export { SnackbarProvider } from './SnackbarProvider';
+export { useSnackbar } from './hooks/useSnackbar';
+export type * from './Snackbar.type';
diff --git a/src/components/index.ts b/src/components/index.ts
index bae9dc8..94d7216 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -54,3 +54,7 @@ export type { SwitchProps } from './Switch';
export { Divider } from './Divider';
export type { DividerProps } from './Divider';
+
+export { SnackbarProvider } from './Snackbar/SnackbarProvider';
+export { useSnackbar } from './Snackbar/hooks/useSnackbar';
+export type * from './Snackbar';