Shader-driven backstack transitions with Navigation 3
Hi everyone! Recently, Navigation 3 finally made me seriously rethink how navigation animations can be built in Compose.
I usually write tutorials about AGSL shaders in Compose, but this article will be a bit different. This time, we’ll focus much more on the Compose side of things, while the shader itself is mostly here as a visual example of what kind of possibilities we suddenly get access to.
The key thing is that Navigation 3 exposes navigation as regular state owned by the app. Because of that, we suddenly gain access to a lot of data that previously was either hard to reach or awkward to synchronize with rendering logic. We can know which destination is below the current one, keep multiple destinations rendered at the same time, delay stack mutations, react to predictive back progress directly, track gesture position and swipe edge, and combine all of that with Compose rendering and AGSL shaders in a very natural way.
And once you start thinking about navigation this way, you realize that predictive back animations no longer have to be just “screen slides away”. The same data can drive completely different effects: canvas deformations, liquid reveals, wave distortions, dynamic masking, fake 3D rotations, stretching effects, depth simulations, or even effects that partially reveal multiple layers of the UI at once. The shader from this tutorial is simply one possible implementation built on top of that idea.
So this tutorial will focus much more on the Compose and Navigation 3 side than usual. First, we’ll go through the architecture itself and understand why Navigation 3 makes these kinds of effects surprisingly clean to implement. Then we’ll build the shader layer in detail. And finally, I’ll also show a few other examples of effects that become possible once navigation state and predictive back data are treated as rendering input instead of just transition callbacks.
In the end we will get something looking like that:
Before we get started, just a quick note — I don’t make a tutorial for every effect I create, but you can find all of them on my GitHub. You’ll also find videos, announcements about new effects, and answers to your questions in my Telegram channel. I’d be happy to see you there!
In the beginning, I’d like to say a few words about Navigation 3 itself — what the core idea behind it actually is, and why it feels so revolutionary.
The concept is honestly very interesting. Previously (in nav2) the whole system was mostly focused on the final destination. We would give the navigation library some target screen and expect it to simply display it for us. Navigation 3 took a completely different approach: instead of hiding navigation behind a controller, it gives us direct access to the entire back stack as regular state. In practice, this means we now have the whole navigation stack as a simple list that we can access at any moment. And that changes a lot! Because now we can simultaneously render two, three, or even five different screens from that stack if we want to, and everything will still be properly composed and respect its own lifecycle correctly.
This opens up a huge amount of possibilities, because now we suddenly have access to all the navigation data that we can feed directly into shaders and start doing all kinds of AGSL magic with it.
I’ll skip the implementation of the demo screens themselves because, first, they are not really important here, and second, I don’t want to clutter the tutorial with unnecessary code. Roughly speaking, the minimal setup required to start working with Navigation 3 looks something like this:
sealed class Route : NavKey {
@Serializable
data object Login : Route()
@Serializable
data object Feed : Route()
}
@Composable
fun LoginScreen(onLogin: () -> Unit) {
// login screen
}
@Composable
fun FeedScreen() {
// feed screen
}
@Composable
fun RouteContent(
route: Route,
onScreenSelected: (Route) -> Unit = { },
) {
when (route) {
is Route.Login -> LoginScreen(
onLogin = {
onScreenSelected(
Route.Feed
)
}
)
is Route.Feed -> FeedScreen()
}
}
@Composable
fun RuntimeShadersNavHost() {
val backStack = rememberNavBackStack(Route.Login)
Box(modifier = Modifier.fillMaxSize()) {
val backEventState = rememberNavigationEventState(
currentInfo = NavigationEventInfo.None,
backInfo = if (backStack.size > 1) {
listOf(NavigationEventInfo.None)
} else {
emptyList()
}
)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider<NavKey> {
entry<Route.Login> { route ->
RouteContent(
route = route,
onScreenSelected = { backStack.add(it) }
)
}
entry<Route.Feed> { route ->
RouteContent(
route = route,
)
}
}
)
}
NavigationBackHandler(
state = backEventState,
isBackEnabled = backStack.size > 1,
onBackCompleted = {
backStack.removeLastOrNull()
}
)
}
}
}As a result, we’ll get two screens with a completely standard transition between them. Pressing the Login button opens the second screen, and using the Back gesture returns us to the first one — nothing magical or particularly interesting yet.
But notice that now we have this BackEventState. Let’s try to use it to create our own PredictiveBackShaderState with exactly the information we need: which edge the Back gesture started from, where exactly it started vertically on the screen, and what its current progress is.
data class PredictiveBackShaderState(
val progress: Float,
val touch: Offset,
val swipeEdge: Int
)// already existing backEventState:
val backEventState = rememberNavigationEventState(
currentInfo = NavigationEventInfo.None,
backInfo = if (backStack.size > 1) {
listOf(NavigationEventInfo.None)
} else {
emptyList()
}
)
// now gathering info from that:
var gestureSession by remember { mutableIntStateOf(0) }
val gestureShaderState = backEventState.toShaderState(resetToken = gestureSession)@Composable
fun NavigationEventState<NavigationEventInfo.None>.toShaderState(
resetToken: Int
): PredictiveBackShaderState {
val transitionState = transitionState
val latestEvent = (transitionState as? NavigationEventTransitionState.InProgress)?.latestEvent
val startTouchY = remember { mutableStateOf<Float?>(null) }
val lastTouch = remember { mutableStateOf(Offset.Zero) }
val lastSwipeEdge = remember { mutableIntStateOf(NavigationEvent.EDGE_NONE) }
LaunchedEffect(resetToken) {
startTouchY.value = null
lastTouch.value = Offset.Zero
lastSwipeEdge.intValue = NavigationEvent.EDGE_NONE
}
if (latestEvent != null && startTouchY.value == null) {
startTouchY.value = latestEvent.touchY
}
if (latestEvent != null) {
val touch = Offset(
x = when (latestEvent.swipeEdge) {
NavigationEvent.EDGE_RIGHT -> Float.POSITIVE_INFINITY
else -> 0f
},
y = startTouchY.value ?: latestEvent.touchY
)
lastTouch.value = touch
lastSwipeEdge.intValue = latestEvent.swipeEdge
}
return PredictiveBackShaderState(
progress = latestEvent?.progress ?: 0f,
touch = lastTouch.value,
swipeEdge = lastSwipeEdge.intValue
)
}Now we have enough information about the gesture itself. The only thing left is to take the previous screen from the back stack and render it underneath the current one:
var activeUnderlayRoute by remember { mutableStateOf<Route?>(null) }
LaunchedEffect(gestureShaderState.progress) {
if (gestureShaderState.progress > 0f) {
if (activeUnderlayRoute == null) {
activeUnderlayRoute = backStack.dropLast(1).lastOrNull() as? Route
}
}
}Box(modifier = Modifier.fillMaxSize()) {
val underlayRoute = if (gestureShaderState.progress > 0f) {
activeUnderlayRoute ?: backStack.dropLast(1).lastOrNull() as? Route
} else {
null
}
if (underlayRoute != null) {
RouteContent(
route = underlayRoute,
onEffectSelected = { backStack.add(it) }
)
}
Box(
modifier = Modifier
.offset(x = 150.dp * gestureShaderState.progress)
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider<NavKey> {
entry<Route.Login> { route ->
RouteContent(
route = route,
onEffectSelected = { backStack.add(it) }
)
}
entry<Route.Feed> { route ->
RouteContent(
route = route,
)
}
}
)
}
}I’ve also wrapped NavDisplay into Box and add offset to progress. Now we already can see, that our state is working:
So, in general, we’re almost done with the Compose part. There are still a few things that we’ll need to fix later, but I’ll leave them until the very end, when the effect is already working, so these problems will be much easier to see.
We also still need to add the shader itself and, inside the Box, replace the offset with this shader. After that, we’ll be ready to move to the most interesting part: writing the shader itself!
val shader = remember { RuntimeShader(predictiveBackHumpMaskShader) }
//rest of the code
Box(
modifier = Modifier.graphicsLayer {
if (gestureShaderState.progress > 0f) {
val centerX = if (gestureShaderState.touch.x.isInfinite()) size.width else gestureShaderState.touch.x
shader.setFloatUniform("resolution", size.width, size.height)
shader.setFloatUniform("touch", centerX, gestureShaderState.touch.y)
shader.setFloatUniform("progress", gestureShaderState.progress)
shader.setFloatUniform("edge", gestureShaderState.swipeEdge.toFloat())
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
} else {
renderEffect = null
}
}
) {
//NavDisplay
}
val predictiveBackHumpMaskShader = """
uniform shader image;
uniform float progress;
uniform float2 resolution;
uniform float2 touch;
uniform float edge;
half4 main(float2 fragCoord) {
half4 color = image.eval(fragCoord);
return half4(color);
}
""".trimIndent()That’s it! Right now the effect does not display anything yet, but at this point we already have everything we need to start implementing the actual shader logic.
Let’s start by creating the line itself.
To do that, we’ll calculate the vertical distance to the touch point and normalize this value by dividing it by the screen height, which we already have in resolution.y. Then we’ll apply smoothstep to get a smooth interpolation.
float verticalDistance = abs(fragCoord.y - touch.y) / resolution.y;
float verticalProfile = 1.0 - smoothstep(0.0, 0.5, verticalDistance);As a result, from the touch point upward and downward, we’ll get a smoothly fading wave.
Get Alex Volkov’s stories in your inbox
Join Medium for free to get updates from this writer.
For the horizontal direction, for now, we’ll simply take the progress and multiply it by half of the screen width. This gives us a uniform wave expansion up to the middle of the screen.
float easedProgress = clamp(progress, 0.0, 1.0);
// we can drag from both left or right edge:
float fromEdge = edge < 0.5 ? fragCoord.x : resolution.x - fragCoord.x;
float maxReach = 0.5 * resolution.x * easedProgress;Now combining those two parameters together:
float waveFront = maxReach * verticalProfile;And finally, let’s add rendering for this line.
float line = 1.0 - smoothstep(0.0, 3.0, abs(fromEdge - waveFront));
return mix(color, half4(1.0, 0.0, 0.0, 1.0), half(line));In the end, we’ll get an effect like this:
Next, I slightly changed the shape of the wave by raising the value to the power of
2.5. I also made the progress non-linear by applying a cubic easing, so at the beginning of the drag the wave grows more aggressively, and closer to the end of the gesture it slows down.But all of this is mostly a matter of taste and does not change the core idea of the effect. I’m explaining it here just so these “magic” numbers and transformations don’t look like they appeared from nowhere.
As a result, the code now looks like this, and the wave itself becomes narrower and reaches its peak faster at the beginning of the gesture.
half4 main(float2 fragCoord) {
half4 color = image.eval(fragCoord);
float easedProgress = CubicOut(clamp(progress, 0.0, 1.0));
float fromEdge = edge < 0.5 ? fragCoord.x : resolution.x - fragCoord.x;
float verticalDistance = abs(fragCoord.y - touch.y) / resolution.y;
float verticalProfile = 1.0 - smoothstep(0.0, 0.5, verticalDistance);
verticalProfile = pow(verticalProfile, 2.5);
float maxReach = 0.5 * resolution.x * easedProgress;
float waveFront = maxReach * verticalProfile;
float line = 1.0 - smoothstep(0.0, 3.0, abs(fromEdge - waveFront));
return mix(color, half4(1.0, 0.0, 0.0, 1.0), half(line));
}Now let’s add alpha and use a mask to cut off everything that lies to the left of our wave function:
float mask = step(fromEdge, waveFront);
float alpha = 1.0 - mask;
return half4(color.rgb * alpha, color.a * alpha);Don’t forget that in AGSL you also need to multiply the RGB values by alpha in order to get correct transparency.
Now let’s add the deformation.
For this effect, it is surprisingly simple. We take the normalized distance from the current point — the pixel, not the touch point — to the screen edge where the effect starts. It can be either the left or the right edge, because the Back gesture can start from both sides.
Then, using smoothstep, we create a smooth falloff from the edge. In other words, the farther the pixel is from the edge, the smaller the displacement becomes.
After that, we multiply this value by our wave function, and also flip the direction if the gesture starts from the other side of the screen.
And now the only thing left is to sample the texture using these modified coordinates — and the effect is basically ready.
float2 sampleCoord = fragCoord;
float normalizedFromEdge = fromEdge / resolution.x;
float edgeInfluence = 1.0 - smoothstep(0.02, 0.8, normalizedFromEdge);
float horizontalWarp = waveFront * edgeInfluence * 0.62;
sampleCoord.x -= edgeSign * horizontalWarp;
half4 warpedColor = image.eval(sampleCoord);
return half4(warpedColor.rgb * alpha, warpedColor.a * alpha);I also added a few extra coefficients and some vertical influence to make the final result look more impressive. But again, the core idea looks exactly like this, and it should already be enough to understand how the effect works. Tweaking and tuning shader effects is honestly the most satisfying part, so I really don’t want to steal that chance from you. You can also check out my GitHub if you want to see the effect in motion from the very beginning of the tutorial and get a more direct 1-to-1 feel for how it behaves in practice.
The last part is for shadow!
We already have a wave that erases the current screen. The shadow is just an additional semi-transparent dark area near this boundary, so the edge does not look flat. For that, we’ll add alphaFeather- this is the width of the soft edge in pixels. At the beginning, the edge is thinner, and closer to the end it becomes slightly softer. We’ll also change the mask from a harsh, rough transition to a softer version using smoothstep.
Now, the shadow itself.
shadowDistance is the distance from the current pixel to the wave front. If shadowDistance > 0, the pixel is inside the revealed area. If it is zero, the pixel is exactly on the boundary. And if it is less than zero, the pixel is still inside the active screen area.ShadowBand is a wide shadow strip near the wave front. With smoothstep, we make this strip smooth, and then invert it, because the shadow should be stronger the closer it is to the wave.
And that’s it. The only thing left is to calculate the shadow itself: we multiply the mask by the shadow band and limit it to 15%, meaning the maximum opacity of our shadow will be 15%.
Here’s how it looks in code:
//rest of our shader code
float alphaFeather = mix(2.5, 7.0, easedProgress);
float mask = 1.0 - smoothstep(waveFront - alphaFeather, waveFront + alphaFeather, fromEdge);
mask *= smoothstep(0.0, 0.08, easedProgress);
float alpha = 1.0 - mask;
float shadowDistance = waveFront - fromEdge;
float shadowBand = 1.0 - smoothstep(-alphaFeather * 2.0, alphaFeather * 22.0, shadowDistance);
float shadow = shadowBand * mask * 0.15;
float finalAlpha = max(warpedColor.a * alpha, shadow);
return half4(warpedColor.rgb * alpha, finalAlpha);Now it’s time to take a look at the some issues, that I promissed to highlight and fix at the end. First of all our vertical point is calculated only once, we have to reset it when back gesture is cancelled:
NavigationBackHandler(
state = backEventState,
isBackEnabled = backStack.size > 1,
onBackCancelled = {
activeUnderlayRoute = null
gestureSession++
},Also, if we completed our gesture, we will see for a moment the whole active view before navigating back, and no animation to nice dissapearing of it. I’ve fixed it with skipping some frames and also animating progress to some big value (envolving modifing the shader also to have completion variable, since progress is clamped between 0 and 1)
onBackCompleted = {
scope.launch {
terminalProgress.animateTo(
targetValue = BACK_COMPLETE_PROGRESS,
animationSpec = tween(durationMillis = BACK_COMPLETE_DURATION_MS)
)
delay(BACK_POP_DELAY_MS.milliseconds)
backStack.removeLastOrNull()
repeat(BACK_RESET_FRAME_DELAY) {
withFrameNanos { } //skiping some frames, ugly but working solution
}
terminalProgress.snapTo(0f)
activeUnderlayRoute = null
gestureSession++
}
}
withFrameNanos { }explicitly waits for the nextvsyncsignal fromChoreographer. Each call suspends the coroutine until the render loop is ready to draw the next frame. Sorepeat(N) { withFrameNanos { } }basically means: wait forNfull frames before continuing. This guarantees that by the time we callsnapTo(0f)and setactiveUnderlayRoute = null, Compose has already had enough frames to finish recomposition afterremoveLastOrNull()and commit the final visual state of the new top screen. In other words, we reset the temporary shader state at a safe frame boundary, not at some random moment between frames.
And finally, as promised, here are a few effects that immediately came to my mind once I got access to all this predictive back gesture data:
Thanks for reading! If you find my experiments fun and my explanations helpful, feel free to join my Telegram channel or follow me on Twitter (X). This project is just a hobby, and honestly, my motivation heavily depends on the feedback I get — so I’d be really happy to see you in the channel. And if you feel like it, I’d truly appreciate it if you shared this post on your social media. Happy coding!










