How to bring your React Native apps to life using sensors

Developmentβ€’β€’9 minutes read

Enzo Manuel Mangano

Enzo Manuel Mangano

Guest Author

Want to recreate the animated 3D welcome screen from the Linear App?

How to bring your React Native apps to life using sensors

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.

Linear's app welcome screen

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:

Code
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.

Code
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.

Code
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 πŸ•ΊπŸΌ

Code
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` },
],
};
});
iOS dancing square

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.

Code
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}>
<Canvas
style={{
height: CanvasSize.height,
width: CanvasSize.width,
}}>
<Group origin={CanvasCenter} transform={rTransform}>
<RoundedRect
x={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.

Code
// ... existing code
return (
<View style={styles.fill}>
<Canvas style={StyleSheet.absoluteFill}>
<Fill>
<RadialGradient
c={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:

  1. 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.
  2. 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.
Code
const App = () => {
// ... existing code
const 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.

Code
// ... existing code
return (
<View style={styles.fill}>
{...} {/* The background RadialGradient */}
<View style={styles.container}>
<Canvas
style={{
height: CanvasSize.height,
width: CanvasSize.width,
}}>
<Group origin={CanvasCenter} transform={rTransform}>
<RoundedRect
x={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?

Dancing shadows demo

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.

Code
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 = () => (
<Oval
x={-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>
<Group
transform={[
{ translateX: canvasSize.width / 2 },
{ translateY: canvasSize.height / 2 },
]}
>
{rotatedOvals}
</Group>
<BlurMask blur={3} style="solid" />
</Group>
);
};
Code
// ... existing code
return (
<View style={styles.fill}>
{...} {/* The background RadialGradient */}
<View style={styles.container}>
<Canvas
style={{
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:

Code
// ... existing code
// I guess this is a good name for it 🀷🏼
const GoodOldSquare = useCallback(
({ children }: { children?: React.ReactNode }) => {
return (
<RoundedRect
x={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.

Code
// ... existing code
return (
<View style={styles.fill}>
{...} {/* The background RadialGradient */}
<View style={styles.container}>
<Canvas
style={{
height: CanvasSize.height,
width: CanvasSize.width,
}}>
<Group origin={CanvasCenter} transform={rTransform}>
<Group>
{/* The base square */}
<GoodOldSquare />
{/* The square with the light shining through */}
<GoodOldSquare>
<LinearGradient
start={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:

  1. Capture device rotation and gravity data using Reanimated's useAnimatedSensor
  2. Implement smooth animations for both X and Y-axis rotations
  3. 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.

We built two apps at once.

Reanimated
Skia

Create amazing apps, in record time with EAS

Learn more