diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
index 0cf66c03..fb59f5ec 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
@@ -37,6 +37,9 @@ import dev.krtirtho.spotube.core.DebugConfig
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
+import dev.krtirtho.spotube.core.navigation.navPopTransitionSpec
+import dev.krtirtho.spotube.core.navigation.navPredictivePopTransitionSpec
+import dev.krtirtho.spotube.core.navigation.navPushTransitionSpec
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
@@ -210,7 +213,10 @@ fun App(
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
- entries = navigationState.toEntries(koinEntryProvider())
+ entries = navigationState.toEntries(koinEntryProvider()),
+ transitionSpec = navPushTransitionSpec,
+ popTransitionSpec = navPopTransitionSpec,
+ predictivePopTransitionSpec = navPredictivePopTransitionSpec
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt
new file mode 100644
index 00000000..742ac190
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package dev.krtirtho.spotube.core.navigation
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.ui.unit.IntOffset
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.scene.Scene
+
+private const val TRANSITION_DURATION_MILLIS = 300
+private const val SLIDE_FRACTION = 0.08f
+private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
+
+// NavEntry.contentKey defaults to key.toString()
+private val topLevelContentKeys = TOP_LEVEL_ROUTES.map { it.toString() }.toSet()
+
+/**
+ * The tab a scene belongs to: the last top-level route in its stack. Switching tabs changes
+ * this, while pushing/popping pages inside a tab doesn't.
+ */
+private fun Scene.tabRoot(): Any? =
+ (previousEntries + entries).lastOrNull { it.contentKey in topLevelContentKeys }?.contentKey
+
+private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean =
+ initialState.tabRoot() != targetState.tabRoot()
+
+/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */
+private fun AnimatedContentTransitionScope>.sharedAxisX(
+ forward: Boolean
+): ContentTransform {
+ if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None)
+
+ val direction = if (forward) 1 else -1
+ val slideSpec = tween(
+ TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate
+ )
+ val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } +
+ fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6))
+ val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } +
+ fadeOut(tween(TRANSITION_DURATION_MILLIS / 3))
+ return enter togetherWith exit
+}
+
+val navPushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform =
+ { sharedAxisX(forward = true) }
+
+val navPopTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform =
+ { sharedAxisX(forward = false) }
+
+val navPredictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform =
+ { sharedAxisX(forward = false) }