diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index b268ef3..1a2ac25 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,14 @@
+
+
+
+
+
+
+
+
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 44ca2d9..fb93d25 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,6 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/other.xml b/.idea/other.xml
deleted file mode 100644
index 94c96f6..0000000
--- a/.idea/other.xml
+++ /dev/null
@@ -1,318 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/yourssu/handy/demo/SnackBarPreview.kt b/app/src/main/kotlin/com/yourssu/handy/demo/SnackBarPreview.kt
new file mode 100644
index 0000000..c02b395
--- /dev/null
+++ b/app/src/main/kotlin/com/yourssu/handy/demo/SnackBarPreview.kt
@@ -0,0 +1,51 @@
+package com.yourssu.handy.demo
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.yourssu.handy.compose.ErrorSnackBar
+import com.yourssu.handy.compose.HandyTheme
+import com.yourssu.handy.compose.InfoSnackBar
+
+@Preview(showBackground = true)
+@Composable
+fun InfoSnackBarPreview() {
+ HandyTheme {
+ Column(
+ modifier = Modifier.padding(10.dp)
+ ) {
+ InfoSnackBar(
+ text = "한 줄짜리 정보성 메세지가 들어갑니다."
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ InfoSnackBar(
+ text = "줄 수가 두 줄 이상이 되는 스낵바 메시지입니다.\n좌측 정렬을 해주세요."
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun ErrorSnackBarPreview() {
+ HandyTheme {
+ Column(
+ modifier = Modifier.padding(10.dp)
+ ) {
+ ErrorSnackBar(
+ text = "에러 메세지가 들어갑니다",
+ onClick = {}
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ ErrorSnackBar(
+ text = "두 줄 이상의 에러 메세지가 들어갈 경우\n 아이콘은 모두 위로 정렬해주세요.",
+ onClick = {}
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBar.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBar.kt
new file mode 100644
index 0000000..6f854f9
--- /dev/null
+++ b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBar.kt
@@ -0,0 +1,108 @@
+package com.yourssu.handy.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import com.yourssu.handy.compose.foundation.HandyTypography
+import com.yourssu.handy.compose.icons.HandyIcons
+import com.yourssu.handy.compose.icons.filled.AlertTriangle
+import com.yourssu.handy.compose.icons.line.Close
+
+/**
+ * 정보성 스낵바의 UI를 그린 함수입니다.
+ *
+ * 유저의 행동에 대한 단순 결과를 나타낼 때 사용합니다.
+ * 특정 시간 노출 후에 사라집니다.
+ *
+ * @param text 스낵바의 문구를 나타내는 텍스트, 최대 두 줄까지 입력 가능
+ * @param modifier Modifier
+ */
+@Composable
+fun InfoSnackBar(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(HandyTheme.colors.snackBarInfo)
+ .padding(16.dp)
+ ) {
+ Text(
+ text = text,
+ color = HandyTheme.colors.textBasicWhite,
+ maxLines = 2,
+ style = HandyTypography.B3Sb14
+ )
+ }
+}
+
+/**
+ * 에러 스낵바의 UI를 그린 함수입니다.
+ *
+ * 사용자의 수행 과정에 부정적인 결과가 발생하거나
+ * 정보성 스낵바보다 강조해야 할 메시지를 담아야 할 때 사용합니다.
+ *
+ * X 버튼을 눌러야만 사라집니다.
+ *
+ * @param text 스낵바의 문구를 나타내는 텍스트, 최대 두 줄까지 입력 가능
+ * @param onClick 스낵바의 X 버튼을 눌렀을 때 호출되는 함수
+ * @param modifier Modifier
+ */
+@Composable
+fun ErrorSnackBar(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(HandyTheme.colors.snackBarError)
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = HandyIcons.Filled.AlertTriangle,
+ tint = HandyTheme.colors.bgStatusNegative,
+ modifier = Modifier.align(Alignment.Top)
+ )
+ Text(
+ text = text,
+ color = HandyTheme.colors.textStatusNegative,
+ maxLines = 2,
+ style = HandyTypography.B3Sb14
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Icon(
+ imageVector = HandyIcons.Line.Close,
+ tint = HandyTheme.colors.textBasicTertiary,
+ modifier = Modifier
+ .clickable(onClick = onClick)
+ .align(Alignment.Top)
+ )
+ }
+}
+
+object SnackBarDefaults {
+ const val SNACK_BAR_DURATION = 5000L
+ const val FADE_IN_DURATION = 500
+ const val FADE_OUT_DURATION = 300
+ const val TARGET_VALUE = -16f
+}
\ No newline at end of file
diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBarAnimation.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBarAnimation.kt
new file mode 100644
index 0000000..8a0ab91
--- /dev/null
+++ b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackBarAnimation.kt
@@ -0,0 +1,138 @@
+package com.yourssu.handy.compose
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.EaseOut
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.offset
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.RecomposeScope
+import androidx.compose.runtime.State
+import androidx.compose.runtime.currentRecomposeScope
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.unit.dp
+import com.yourssu.handy.compose.SnackBarDefaults.FADE_IN_DURATION
+import com.yourssu.handy.compose.SnackBarDefaults.FADE_OUT_DURATION
+import com.yourssu.handy.compose.SnackBarDefaults.TARGET_VALUE
+
+data class SnackBarTransitionItem(
+ val snackBarData: SnackBarData?,
+ val opacityTransition: OpacityTransition
+)
+
+typealias OpacityTransition = @Composable (snackBar: @Composable () -> Unit) -> Unit
+
+@Composable
+fun FadeInFadeOut(
+ newSnackBarData: SnackBarData?,
+ modifier: Modifier = Modifier,
+ snackBar: @Composable (SnackBarData) -> Unit
+) {
+ var scheduledSnackBarData by remember { mutableStateOf(null) }
+ val snackBarTransitions = remember { mutableListOf() }
+ var scope by remember { mutableStateOf(null) }
+
+ if (newSnackBarData != scheduledSnackBarData) {
+ scheduledSnackBarData = newSnackBarData
+
+ val snackBarDataList = snackBarTransitions.map { it.snackBarData }.toMutableList()
+
+ snackBarDataList.add(newSnackBarData)
+
+ snackBarTransitions.clear()
+
+ snackBarDataList.filterNotNull()
+ .mapTo(destination = snackBarTransitions) { appearedSnackBarData ->
+ SnackBarTransitionItem(appearedSnackBarData) { snackBar ->
+ val isVisible = appearedSnackBarData == newSnackBarData
+ val animateInSpec: AnimationSpec =
+ tween(durationMillis = FADE_IN_DURATION)
+ val animateOutSpec: AnimationSpec =
+ tween(durationMillis = FADE_OUT_DURATION, easing = EaseOut)
+
+ val opacity = animatedOpacity(
+ visible = isVisible,
+ animateInSpec = animateInSpec,
+ animateOutSpec = animateOutSpec
+ )
+
+ val offsetY = animatedOffset(
+ visible = isVisible,
+ animateInSpec = animateInSpec,
+ animateOutSpec = animateOutSpec
+ )
+
+ Box(
+ modifier = Modifier
+ .offset(y = offsetY.value.dp)
+ .alpha(opacity.value)
+ ) {
+ snackBar()
+ }
+ }
+ }
+ }
+
+ Box(
+ modifier = modifier
+ ) {
+ scope = currentRecomposeScope
+ snackBarTransitions.forEach { (snackBarData, opacity) ->
+ key(snackBarData) {
+ opacity {
+ snackBar(snackBarData ?: return@opacity)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun animatedOpacity(
+ visible: Boolean,
+ animateInSpec: AnimationSpec,
+ animateOutSpec: AnimationSpec,
+): State {
+ val alpha = remember { Animatable(0f) }
+
+ LaunchedEffect(visible) {
+ alpha.animateTo(
+ if (visible) 1f else 0f,
+ animationSpec = if (visible) animateInSpec else animateOutSpec
+ )
+ }
+ return alpha.asState()
+}
+
+@Composable
+private fun animatedOffset(
+ visible: Boolean,
+ animateInSpec: AnimationSpec,
+ animateOutSpec: AnimationSpec,
+): State {
+ val offsetY = remember { Animatable(0f) }
+
+ LaunchedEffect(visible) {
+ if (visible) {
+ offsetY.animateTo(
+ targetValue = TARGET_VALUE,
+ animationSpec = animateInSpec
+ )
+ } else {
+ offsetY.animateTo(
+ targetValue = 0f,
+ animationSpec = animateOutSpec
+ )
+ }
+ }
+
+ return offsetY.asState()
+}
diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/SnackData.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackData.kt
new file mode 100644
index 0000000..38644f6
--- /dev/null
+++ b/compose/src/main/kotlin/com/yourssu/handy/compose/SnackData.kt
@@ -0,0 +1,87 @@
+package com.yourssu.handy.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.yourssu.handy.compose.SnackBarDefaults.SNACK_BAR_DURATION
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.coroutines.resume
+
+interface SnackBarData {
+ val message: String
+
+ fun dismiss()
+}
+
+enum class SnackBarResult {
+ Dismissed,
+}
+
+@Stable
+class SnackBarHostState {
+ private val mutex = Mutex()
+
+ internal var currentSnackBarData by mutableStateOf(null)
+ private set
+
+ suspend fun showToast(
+ message: String,
+ ): SnackBarResult = mutex.withLock {
+ try {
+ return suspendCancellableCoroutine { continuation ->
+ currentSnackBarData = SnackBarDataImpl(message, continuation)
+ }
+ } finally {
+ currentSnackBarData = null
+ }
+ }
+}
+
+@Composable
+fun rememberSnackBarHostState(): SnackBarHostState = remember { SnackBarHostState() }
+
+@Stable
+private class SnackBarDataImpl(
+ override val message: String,
+ private val continuation: CancellableContinuation
+) : SnackBarData {
+
+ override fun dismiss() {
+ if (continuation.isActive) {
+ continuation.resume(SnackBarResult.Dismissed)
+ }
+ }
+}
+
+@Composable
+fun SnackBarHost(
+ snackBarHostState: SnackBarHostState,
+ modifier: Modifier = Modifier,
+ snackBar: @Composable (SnackBarData) -> Unit = {
+ InfoSnackBar(
+ text = snackBarHostState.currentSnackBarData?.message ?: ""
+ )
+ }
+) {
+ val currentSnackBarData = snackBarHostState.currentSnackBarData
+ LaunchedEffect(currentSnackBarData) {
+ if (currentSnackBarData != null) {
+ delay(SNACK_BAR_DURATION)
+ currentSnackBarData.dismiss()
+ }
+ }
+ FadeInFadeOut(
+ newSnackBarData = snackBarHostState.currentSnackBarData,
+ modifier = modifier,
+ snackBar = snackBar
+ )
+}
diff --git a/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt b/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt
index d668f4f..e0d3bd9 100644
--- a/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt
+++ b/compose/src/main/kotlin/com/yourssu/handy/compose/foundation/SemanticColors.kt
@@ -112,7 +112,13 @@ data class ColorScheme(
// Pagination / Basic
val paginationBasicSelected: Color = ColorNeutralBlack,
- val paginationBasicUnselected: Color = ColorGray500
+ val paginationBasicUnselected: Color = ColorGray500,
+
+
+ // SnackBar
+ val snackBarInfo: Color = ColorGray800,
+ val snackBarError: Color = ColorStatusRedSub
+
)
val lightColorScheme = ColorScheme()