How to bring your React Native apps to life using sensors
Developmentβ’β’9 minutes read
Enzo Manuel Mangano
Guest Author
Want to recreate the animated 3D welcome screen from the Linear App?

This is a guest post from Enzo Manuel Mangano - he's a Software Engineer and Educator who is deeply passionate about pushing the boundaries of React Native animations.
...
More than 2.2 million apps are available on the Google Play Store and over 2 million apps on the App Store. Every day, more than 1000 new apps are released on both platforms.
So how are you supposed to make your app stand out?
Many factors contribute to a successful app but one of them is the obsessive pursuit of a great user experience. Your app has to be fun and engaging.
You must consistently delight your users and search for that "wow" moment in every single interaction.
With this article, we'll explore how you can easily leverage your device's sensors to add that "extra something" to your app.
Here's the plan
To keep things practical, here's our goal: building a square that follows the device's movement.
The animation is clearly inspired by Linear's app welcome screen, which has quickly become a benchmark in app design.
Source code
Before diving into the article, you might want to check out the source code on GitHub, which contains the complete implementation of the tutorial and might be helpful to follow along.
Here's the link: https://github.com/enzomanuelmangano/expo-sensors-demo
The recipe
Each animation has a unique recipe. You need to gather the right ingredients and follow a precise recipe to make it work.
- The useAnimatedSensor hook from React Native Reanimated: it lets you create animations based on data from the device's sensors
- React Native Skia: a package that brings the Skia Graphics Library to React Native. It'll be needed to animate shadows and to build the gradients.
Setting up the first brick
To get started, we'll create a basic expo project and install the dependencies:
npx expo install @shopify/react-native-skia react-native-reanimated
Then we'll start by adding a black square in the center of the screen.
const App = () => {return (<View style={styles.container}><Animated.View style={styles.square} /></View>);};const styles = StyleSheet.create({container: {flex: 1,justifyContent: 'center',alignItems: 'center',},square: {height: SquareSize,width: SquareSize,backgroundColor: 'black',borderRadius: 35,borderCurve: 'continuous',},});
It may not seem like much, but don't forget that before Pinocchio came to life, he was merely a block of wood.
Finding the key points
Before proceeding, we must first observe the device's movement to identify the key points of the animation. Essentially, we need to experience the animation from the user's perspective.
Rotation around the Y-axis
We can easily identify three key points that will be the base of our animation on the Y-axis:
- When the device is in its "resting" position
- When the device is slightly rotated on the left side
- When the device is slightly rotated on the right side
But how this will be translated into values?
Luckily, Reanimated's documentation provides an amazing Playground that we can use to find out the exact values.
With this tool, we can directly visualize the values we'll get from the useAnimatedSensor hook by using the "Rotation" sensor.
In detail, we're going to have direct access to Euler angles which are:
- roll: rotation around the front/back axis
- pitch: rotation around the left/right axis
- yaw: rotation around the vertical axis
Roughly speaking, we're going to use the "roll" to understand if the device is in its resting position (roll = 0), if it's slightly rotated on the left side (roll = -1), or if it's slightly rotated on the right side (roll = 1).
With this in mind, we can now transpose this knowledge into code and start building our animation.
const deviceRotation = useAnimatedSensor(SensorType.ROTATION, {interval: 20,});const rotateY = useDerivedValue(() => {const { roll } = deviceRotation.sensor.value;return interpolate(roll,[-1, 0, 1],[Math.PI / 8, 0, -Math.PI / 8],Extrapolation.CLAMP,);});const rStyle = useAnimatedStyle(() => {return {transform: [{ perspective: 200 }, { rotateY: `${rotateY.value}rad` }],};});
The main idea behind this code is that we're using the roll value to interpolate the rotateY value.
Here's how it works:
- When the device is slightly rotated on the left side, the roll value is -1 and the rotateY value is mapped to Math.PI / 8
- When the device is in its resting position, the roll value is 0 and the rotateY value is mapped to 0
- When the device is slightly rotated on the right side, the roll value is 1 and the rotateY value is mapped to -Math.PI / 8
This interpolation allows for a smooth transition between these states, creating a realistic rotation effect based on the device's orientation.
Rotation around the X-axis
Overall, the process of finding out the key points and the values to use for the X-axis is the same as we've just seen for the Y-axis.
We need to identify some keyframes:
- When the device is in its "resting" position
- When the device is slightly rotated down
- When the device is slightly rotated up
The only difference is the sensor we're using. In this case, we'll be using the gravity sensor.
As we did for the Y axis, we can use the Reanimated Playground to find out the exact values to use for our animation.
We can refer to the "z" value to understand if the device is in its resting position (z=-6), if it's slightly rotated down (z=-9), or if it's slightly rotated up (z=-1).
And here comes the dancing square πΊπΌ
const rotationGravity = useAnimatedSensor(SensorType.GRAVITY, {interval: 20,});const rotateX = useDerivedValue(() => {const { z } = rotationGravity.sensor.value;return interpolate(z,[-9, -6, -1],[-Math.PI / 8, 0, Math.PI / 8],Extrapolation.CLAMP,);});const rStyle = useAnimatedStyle(() => {return {transform: [{ perspective: 200 },{ rotateY: `${rotateY.value}rad` },{ rotateX: `${rotateX.value}rad` },],};});
Where are the shadows?
We finally have the feeling that our app is breathing, but there's something missing. Our square is not casting shadows and it doesn't feel like it's interacting with the real world.
This is where React Native Skia comes into play.
1. Moving everything to Skia
Unfortunately, we can't just start adding shadows to our square but to use Skia we need to refactor our code so that it's defined within a Skia Canvas.
import { Canvas, Group, RoundedRect, vec } from '@shopify/react-native-skia';const CanvasSize = {width: 500,height: 500,};const CanvasCenter = vec(CanvasSize.width / 2, CanvasSize.height / 2);const App = () => {const rTransform = useDerivedValue(() => {return [{ perspective: 200 },{ rotateY: rotateY.value },{ rotateX: rotateX.value },];});return (<View style={styles.container}><Canvasstyle={{height: CanvasSize.height,width: CanvasSize.width,}}><Group origin={CanvasCenter} transform={rTransform}><RoundedRectx={CanvasCenter.x - SquareSize / 2}y={CanvasCenter.y - SquareSize / 2}width={SquareSize}height={SquareSize}color="#101010"r={35}/></Group></Canvas></View>);};
This won't change anything visually, but it's a necessary step to be able to add shadows later on.
2. Adding gradients (in-between step)
Since we're now using Skia we can enhance the whole experience by adding a Background RadialGradient.
// ... existing codereturn (<View style={styles.fill}><Canvas style={StyleSheet.absoluteFill}><Fill><RadialGradientc={vec(windowWidth / 2, windowHeight / 2)}r={windowWidth / 1.5}colors={['#252525', '#000000']}/><Blur blur={50} /></Fill></Canvas><View style={styles.container}>{...}</View></View>);};// ... existing code
Here's the result - Can you hear the square screaming "Where are my shadows?!".
3. Shadows
Right now, we can finally start defining the shadows.
In detail, we're going to animate a couple of values:
- dx (delta x): This represents the horizontal offset of the shadow. A positive value moves the shadow to the right, while a negative value moves it to the left.
- dy (delta y): This represents the vertical offset of the shadow. A positive value moves the shadow downwards, while a negative value moves it upwards.
const App = () => {// ... existing codeconst shadowDx = useDerivedValue(() => {return interpolate(rotateY.value,[-Math.PI / 8, 0, Math.PI / 8],[10, 0, -10],Extrapolation.CLAMP,);});const shadowDy = useDerivedValue(() => {return interpolate(rotateX.value,[-Math.PI / 8, 0, Math.PI / 8],// Exception instead of (-10 use 7) that's because the "light source" is on the top[7, 0, 10],Extrapolation.CLAMP,);});// ... existing code}
Then we can add the Shadow components to our square and let the magic happen.
// ... existing codereturn (<View style={styles.fill}>{...} {/* The background RadialGradient */}<View style={styles.container}><Canvasstyle={{height: CanvasSize.height,width: CanvasSize.width,}}><Group origin={CanvasCenter} transform={rTransform}><RoundedRectx={CanvasCenter.x - SquareSize / 2}y={CanvasCenter.y - SquareSize / 2}width={SquareSize}height={SquareSize}color="#101010"r={35}/>{/* π I'm the inner white light on top of the square (faking the light source π‘) */}<Shadow color="#4c4c4c" inner blur={0} dx={0} dy={0.8} />{/* π I'm the animated shadow */}<Shadow color="#000000" blur={3.5} dx={shadowDx} dy={shadowDy} /></Group></Canvas></View></View>);};// ... existing code
Can you feel the difference?
4. Adding the React Native logo
This is totally optional but I think it's a nice touch to add the React Native Logo in the center of the square. Kind of like a "stamp" to make the app feel more complete and to remember that you can achieve whatever you want with React Native.
To create the logo, we'll use the Oval component from React Native Skia and we'll do some transformations to rotate the ovals. The beauty of it is that we can easily apply a glowing effect with a Skia BlurMask.
Once the component is created, we can add it to our canvas and animate it.
import { BlurMask, Group, Oval } from '@shopify/react-native-skia';import { useMemo } from 'react';const SIZE = 60;const OVAL_HEIGHT_RATIO = 2.5;type CanvasSize = {width: number;height: number;};type ReactLogoSkiaProps = {canvasSize: CanvasSize;};const OvalComponent = () => (<Ovalx={-SIZE}y={-SIZE / OVAL_HEIGHT_RATIO}width={SIZE * 2}height={(SIZE / OVAL_HEIGHT_RATIO) * 2}color="#cecece"style="stroke"strokeWidth={2}/>);export const ReactNativeLogo = ({ canvasSize }: ReactLogoSkiaProps) => {const rotatedOvals = useMemo(() => {const angles = [0, Math.PI / 3, (Math.PI * 2) / 3];return angles.map((angle, index) => (<Group key={index} transform={[{ rotate: angle }]}><OvalComponent /></Group>));}, []);return (<Group><Grouptransform={[{ translateX: canvasSize.width / 2 },{ translateY: canvasSize.height / 2 },]}>{rotatedOvals}</Group><BlurMask blur={3} style="solid" /></Group>);};
// ... existing codereturn (<View style={styles.fill}>{...} {/* The background RadialGradient */}<View style={styles.container}><Canvasstyle={{height: CanvasSize.height,width: CanvasSize.width,}}><Group origin={CanvasCenter} transform={rTransform}><Group>{...} {/* The square with the shadow */}</Group><Group><ReactNativeLogo canvasSize={CanvasSize} /> {/* π New addition */}</Group></Group></Canvas></View></View>);};// ... existing code
So far, this is the result we've got:
The beauty always lies in the details
It seems like we're done, but there's always room for improvement. Can you spot the imperfections?
As you can hopefully see, it seems like the expected result has more depth. It just feels more "real".
To get this effect, we're going to add a subtle gradient on the square so that it looks like the light is shining through it.
First of all, let's extract our RoundedRect into a separate component:
// ... existing code// I guess this is a good name for it π€·πΌconst GoodOldSquare = useCallback(({ children }: { children?: React.ReactNode }) => {return (<RoundedRectx={CanvasSize.width / 2 - SquareSize / 2}y={CanvasSize.height / 2 - SquareSize / 2}width={SquareSize}height={SquareSize}color="#101010"r={35}>{children}</RoundedRect>);},[],);// ... existing code
Then we can reuse it in our Canvas and apply a LinearGradient to the GoodOldSquare.
// ... existing codereturn (<View style={styles.fill}>{...} {/* The background RadialGradient */}<View style={styles.container}><Canvasstyle={{height: CanvasSize.height,width: CanvasSize.width,}}><Group origin={CanvasCenter} transform={rTransform}><Group>{/* The base square */}<GoodOldSquare />{/* The square with the light shining through */}<GoodOldSquare><LinearGradientstart={vec(0, 0)}end={vec(0, CanvasSize.height / 1.8)}colors={['#2e2e2e', '#0e0e0e']}/>{/* Blurring the linear gradient is always the secret sauce */}<Blur blur={10} /></GoodOldSquare><Shadow color="#4c4c4c" inner blur={0} dx={0} dy={0.8} /><Shadow color="#000000" blur={3.5} dx={shadowDx} dy={shadowDy} /></Group><Group>{...} {/* The logo β */}</Group></Group></Canvas></View></View>);};// ... existing code
And that's it, finally, our block of wood is alive!
Conclusion
In this article, we've explored how to leverage device sensors to create engaging and interactive animations in React Native. We've taken a simple square and transformed it into a dynamic element that reacts to the device's orientation.
Here's what we've accomplished:
- Capture device rotation and gravity data using Reanimated's useAnimatedSensor
- Implement smooth animations for both X and Y-axis rotations
- Add depth and realism to our animation with crafted shadows and lighting effects using React Native Skia
And just a reminder: we didn't only create a 3D animated square for iOS, we also built an Android version.
Because that's what React Native has always been about.


