August 6, 2026
React native depth transition and theme switching animation with react native skia, reanimated.
Recreating the profile screen interaction from District by Zomato, and the unexpected challenge of animating iOS 26's native tab bar.
Building a Native-Looking Depth Transition in React Native (Skia + Reanimated)
Recreating the profile screen interaction from District by Zomato, and the unexpected challenge of animating iOS 26's native tab bar.
Introduction
When using a polished application, most users don't consciously notice the animations—they simply feel that the app is "premium."
One interaction that immediately caught my attention was the profile screen in District by Zomato. Instead of a traditional bottom sheet, opening the profile creates a convincing depth effect where the background scales away while the sheet rises naturally.
I wanted to recreate that interaction in React Native while maintaining native-level performance.
The result is a fully GPU-driven animation built with:
- React Native Skia
- React Native Reanimated
- Expo Router Native Tabs
The Animation
The interaction consists of several animations happening simultaneously:
- The background scales down slightly.
- Border radius increases to separate foreground and background.
- A subtle shadow is introduced.
- The bottom sheet slides from the bottom.
- The backdrop fades in.
- The native bottom tab bar smoothly changes appearance to match the modal.
Individually, none of these animations are difficult.
Synchronizing all of them at 60 FPS is where things become interesting.
Why Skia?
Most of the visual effects are rendered with React Native Skia.
Instead of relying on expensive view hierarchies, Skia lets the GPU handle much of the work, making complex visual transitions extremely smooth.
Animation values are driven entirely by Reanimated shared values, so almost everything stays on the UI thread.
No bridge traffic.
No dropped frames.
The Unexpected Problem
The animation itself wasn't actually the hardest part.
The real challenge was the native iOS 26 bottom tab bar.
Expo Router recently introduced native tabs, but they're still evolving. While building this interaction, I ran into limitations around dynamically updating the tab bar's appearance during an ongoing animation. This is a known area that's still under development. (GitHub)
Ideally, the tab bar should gradually transition from the normal appearance into the modal appearance.
Instead, the available APIs weren't designed for continuously animated appearance updates.
The Workaround
After experimenting with several approaches, I ended up building a workaround that synchronizes the native tab bar appearance with the animation progress.
Instead of treating the tab bar as something static, its appearance is updated in lockstep with the sheet animation, making the transition appear continuous to the user.
The end result feels completely natural, even though the underlying implementation required working around current framework limitations.
Small Details Matter
Most users will never say,
"Wow, the tab bar animated beautifully."
But they will notice when something feels off.
These tiny interactions are what separate an app that merely works from one that feels genuinely high quality.
Conclusion
This project reminded me that building smooth UI isn't just about writing animation code.
It's about understanding platform limitations, finding creative workarounds when APIs aren't quite there yet, and obsessing over the details users may never consciously notice—but will definitely feel.
Source code;
import COLORS from "@/design/colors";
import { useEffect } from "react";
import {
Fill,
Canvas,
Rect,
LinearGradient,
Group,
vec,
} from "@shopify/react-native-skia";
import { StyleProp, ViewStyle, Dimensions } from "react-native";
import {
interpolateColor,
useAnimatedStyle,
useSharedValue,
useDerivedValue,
withTiming,
withRepeat,
} from "react-native-reanimated";
interface CanvasGradientAnimationProps {
duration?: number;
style?: StyleProp<ViewStyle>;
theme: string;
}
const screenHeight = Dimensions.get("screen").height;
const CanvasGradientAnimation = ({
duration = 1000,
theme,
style,
}: CanvasGradientAnimationProps) => {
const lightColors = [COLORS.textSecondary, COLORS.textSecondary, "white"];
const darkColors = ["white", "white", "#000"];
const translateY = useSharedValue(0);
const opacity = useSharedValue(0);
useEffect(() => {
opacity.value = withTiming(1, { duration: 5000 });
}, [theme, opacity]);
useEffect(() => {
translateY.value = 0;
translateY.value = withRepeat(
withTiming(screenHeight, { duration: 1000 }),
1,
false,
);
}, [translateY, theme]);
const start = useDerivedValue(() => vec(0, screenHeight));
const end = useDerivedValue(() => vec(0, 0 + translateY.value));
const skiaOpacity = useDerivedValue(() => {
return opacity.value;
});
return (
<Canvas style={style}>
<Group opacity={skiaOpacity}>
<Fill>
<LinearGradient
start={start}
end={end}
colors={theme === "light" ? lightColors : darkColors}
/>
</Fill>
</Group>
</Canvas>
);
};
export default CanvasGradientAnimation;
Depth transition
mport { useEffect, useMemo } from "react";
import {
LayoutChangeEvent,
StyleProp,
StyleSheet,
ViewStyle,
} from "react-native";
import Animated, {
Easing,
interpolate,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Canvas, RoundedRect, Shadow } from "@shopify/react-native-skia";
import { duration } from "@/design";
import { useColors } from "@/store/theme";
import createStyles from "./styles";
export interface DepthTransitionProps {
active: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
const SCALE_TARGET = 0.91;
const TRANSLATE_TARGET = 18;
const RADIUS_TARGET = 32;
const SCRIM_TARGET = 0.4;
const DepthTransition: React.FC<DepthTransitionProps> = ({
active,
children,
style,
}) => {
const COLORS = useColors();
const styles = useMemo(() => createStyles(COLORS), [COLORS]);
const layoutWidth = useSharedValue(0);
const layoutHeight = useSharedValue(0);
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withTiming(active ? 1 : 0, {
duration: duration.normal,
easing: Easing.linear,
});
}, [active, progress]);
const scale = useDerivedValue(() =>
interpolate(progress.value, [0, 1], [1, SCALE_TARGET]),
);
const translateY = useDerivedValue(() =>
interpolate(progress.value, [0, 1], [0, TRANSLATE_TARGET]),
);
const cornerRadius = useDerivedValue(() =>
interpolate(progress.value, [0, 1], [0, RADIUS_TARGET]),
);
const rectWidth = useDerivedValue(() => layoutWidth.value * scale.value);
const rectHeight = useDerivedValue(() => layoutHeight.value * scale.value);
const rectX = useDerivedValue(
() => (layoutWidth.value - rectWidth.value) / 2,
);
const rectY = useDerivedValue(
() => (layoutHeight.value - rectHeight.value) / 2 + translateY.value,
);
const cardStyle = useAnimatedStyle(() => ({
borderRadius: cornerRadius.value,
transform: [{ translateY: translateY.value }, { scale: scale.value }],
}));
const scrimStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 1], [0, SCRIM_TARGET]),
}));
const onLayout = (event: LayoutChangeEvent) => {
layoutWidth.value = event.nativeEvent.layout.width;
layoutHeight.value = event.nativeEvent.layout.height;
};
return (
<Animated.View style={[styles.root, style]} onLayout={onLayout}>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
<RoundedRect
x={rectX}
y={rectY}
width={rectWidth}
height={rectHeight}
r={cornerRadius}
color="black"
>
<Shadow dx={0} dy={16} blur={20} color="rgba(0,0,0,0.4)" />
</RoundedRect>
</Canvas>
<Animated.View style={[styles.card, cardStyle]}>
{children}
<Animated.View
style={[styles.scrim, scrimStyle]}
pointerEvents="none"
/>
</Animated.View>
</Animated.View>
);
};
export default DepthTransition;
mport { StyleSheet } from "react-native";
import type { ColorPalette } from "@/design/colors";
const createStyles = (COLORS: ColorPalette) =>
StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#000000",
},
card: {
flex: 1,
backgroundColor: COLORS.background,
overflow: "hidden",
borderCurve: "continuous",
},
scrim: {
...StyleSheet.absoluteFill,
backgroundColor: "#000000",
},
});
export default createStyles;
