The Widlarz Group Blog
Custom color picker animation with React Native Reanimated v2
September 08, 2021
Introduction
A while ago, I had the pleasure of implementing a feature that allows users to choose their favorite color which will be associated with their profile.
This article will be dedicated to how I went about creating such a feature and boosting it with some cool animations using Reanimated v2 in React Native.

Content
- Project Setup
- Before We Begin
- Render Bubbles
- Drag and Drop Bubbles
- Save Color
- Drop Area Animation
- Summary
Project Setup
To make things easier, I prepared a repository with the starter and the completed code. See link
The master branch contains the final animation, while the starter branch features basic setup for starters.
First things first, let’s clone the repo:
git clone -b starter https://github.com/TheWidlarzGroup/reanimated-color-picker.git
Install packages:
cd reanimated-color-picker && yarn install
For iOS also:
cd ios && pod install
Run the project:
yarn ios
// or
yarn android
At this point the project looks like this, there’s no animation, the bubbles render on top of each other and we can’t move them around just yet.

Before We Begin
Please note that this project uses TypeScript
, however you do not need to be familiar with TypeScript
in order to follow and understand the content of this article.
There are two components that we’ll focus on in this article:
-
BubbleContainer - wrapper for our bubbles and drop area.
-
Bubble - each separate bubble.
React Context is used to keep the information about the user’s chosen color available to all the components (See documentation).
All dependencies have already been added to the project, including react-native-reanimated
. Refer to the official docs for the installation and configuration guidelines.
Render Bubbles
Let’s start with rendering the bubbles in random positions with a random delay. We can achieve this by implementing two simple functions.
First, to calculate the initial position of each bubble within our screen view.
const randomFromRange = (min: number, max: number) =>
Math.floor(Math.random() * (max - min + 1) + min);
Then, to calculate render delay of each bubble. Max delay set for this animation is 600ms, feel free to adjust it to your preferences.
const randomDelay = Math.floor(Math.random() * 600);
Let’s move to BubbleContainer.tsx
, where we begin with parsing variable COLORS
to have an additional property: position.
Initially, COLORS is an array of objects in format:
type ColorProps = {
id: number;
color: string;
};
const COLORS: ColorProps[] =
[
{
color: 'red',
id: 1,
}
// ...
]
Parsed and saved to state:
// BubbleContainer.tsx
import { useWindowDimensions } from 'react-native';
import { COLORS } from '../utils/mockedColors';
import { CONSTANTS as C } from '../utils/helpers';
type BubbleProps = {
position: Position;
id: number | string;
color: string;
};
type Position = {
x: number;
y: number;
};
export const BubbleContainer = () => {
const { height, width } = useWindowDimensions();
// ...
const initBubbles = COLORS.map(color => ({
...color,
position: {
x: randomFromRange(C.BUBBLES_OFFSET_LEFT, width - C.BUBBLE_SIZE),
y: randomFromRange(C.BUBBLES_OFFSET_TOP, height - C.BUBBLES_OFFSET_BOTTOM),
},
}));
const [bubbles] = useState<BubbleProps[]>(initBubbles);
// ...
}
Here’s where our function randomFromRange
comes into use. We set the range for x-axis to be from 0px (C.BUBBLES_OFFSET_LEFT) to the width of the window reduced by 56px (C.BUBBLE_SIZE). The range for y-axis varies from 130px (that’s the space that the description message takes - C.BUBBLES_OFFSET_TOP) to the height of the window reduced by the height of the drop area plus the size of the bubble (C.BUBBLES_OFFSET_BOTTOM). By doing so, we prevent rendering any bubble outside of the screen view.
At this point our bubbles render in random positions within the dark grey space.
Now, let’s move to implementing a random render delay.
We move to Bubble.tsx
component.
First, we need to change View
from react-native
to Animated.View
from react-native-reanimated
and wrap it all with PanGestureHandler
from react-native-gesture-handler
, so the component looks like this:
// Bubble.tsx
import { PanGestureHandler } from 'react-native-gesture-handler';
import Animated from 'react-native-reanimated';
// ...
return (
<PanGestureHandler>
<Animated.View
style={[
{
backgroundColor: color,
},
]}
/>
</PanGestureHandler>
)
Now, we implement the actual animation and update the return method accordingly.
Here, useAnimatedStyle
comes in handy. As per the documentation, it allows to create an association between Shared Values and View properties and subscribes to any changes and styles. It takes in a function as an argument and returns an object with new style properties. We use two animation helpers withDelay
and withTiming
to smoothly animate any style changes.
To create an impression that the bubbles render asynchronously, their initial size is set to 0px and we animate the change of width, height and borderRadius. This is controlled by the hook useSharedValue
that creates the reference to Shared Value, which can then be modified by worklets.
// Bubble.tsx
import { useSharedValue } from 'react-native-reanimated';
const bubbleSize = useSharedValue(0);
To read the data from the Shared Value reference, we use .value
attribute.
// Bubble.tsx
import { randomDelay } from '../utils/helpers';
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated';
type Position = {
x: number;
y: number;
};
type BubbleProps = {
color: string;
diameter: number;
position: Position;
dropAreaTop: number;
};
export const Bubble = ({color, diameter, position, dropAreaTop}: BubbleProps) => {
const bubbleSize = useSharedValue(0);
const BubbleStyle = useAnimatedStyle(() => ({
width: withDelay(
randomDelay,
withTiming(bubbleSize.value, {duration: 600}),
),
height: withDelay(
randomDelay,
withTiming(bubbleSize.value, {duration: 600}),
),
borderRadius: withDelay(
randomDelay,
withTiming(bubbleSize.value, {duration: 600}),
),
}));
useEffect(() => {
bubbleSize.value = diameter;
}, [bubbleSize, diameter]);
return (
<PanGestureHandler>
<Animated.View
style={[
BubbleStyle,
{
backgroundColor: color,
},
]}
/>
</PanGestureHandler>
);
}
At this point our animation looks like this:

The bubbles render in random places with random delay (up to 600ms) but we can’t move them around just yet. Let’s implement that now.
Drag and Drop Bubbles
To allow the user to drag the bubbles around, we need to use another hook useAnimatedGestureHandler
. This hook requires react-native-gesture-handler
added and configured in our project. Read more
First, we pass onGestureEvent
prop to PanGestureHander
, like so:
// Bubble.tsx
<PanGestureHandler onGestureEvent={gestureHandler}>
// ...
</PanGestureHandler>
useAnimatedGestureHandler
takes in an object with worklets - gesture handlers that are defined under certain keys and triggered based on the current state of the animation. In this article we’ll cover onStart, onActive and onEnd. Each of them receive event and context as arguments. Event objects hold the event payload and context is able to store some data and keep them available for all worklet handlers.
To make an animated transition of each bubble, we create animation objects (translateX and translateY) that run updates on Shared Value resulting in a smooth change between the two values.
See the code below:
// Bubble.tsx
import {PanGestureHandlerGestureEvent} from 'react-native-gesture-handler';
import {useAnimatedGestureHandler} from 'react-native-reanimated';
// ...
const translateX = useSharedValue(position.x);
const translateY = useSharedValue(position.y);
const viewStyle = useAnimatedStyle(() => ({
transform: [{translateX: translateX.value}, {translateY: translateY.value}],
}));
const gestureHandler = useAnimatedGestureHandler<
PanGestureHandlerGestureEvent,
{
offsetY: number;
offsetX: number;
}
>({
onStart: (_, ctx) => {
ctx.offsetX = translateX.value;
ctx.offsetY = translateY.value;
},
onActive: (event, ctx) => {
translateX.value = ctx.offsetX + event.translationX;
translateY.value = ctx.offsetY + event.translationY;
},
});
return (
<PanGestureHandler>
<Animated.View style={viewStyle}>
<Animated.View
style={[
BubbleStyle,
{
backgroundColor: color,
},
]}
/>
</Animated.View>
</PanGestureHandler>
);
At this point we can already drag around our bubbles but they stop immediately once we release the press, which makes the whole experience quite unnatural.
Let’s try to improve that.
To do so, we can add the onEnd
method to gestureHandler
and use it with withDecay
animation helper to smoothly decelerate the bubble’s speed after the user’s press has been released. We set the velocity to be the same as the velocity of the animation, which basically means that the faster we drag the bubble, the longer it’ll take for it to stop fully.
By using clamp prop, we provide animation the boundaries, which guarantee that the bubble will not leave the screen. The logic for defining boundries is similar to the one already used in randomFromRange
.
// Bubble.tsx
// ...
onEnd: ({velocityX, velocityY}) => {
translateX.value = withDecay({
velocity: velocityX,
clamp: [C.BUBBLES_OFFSET_LEFT, width - diameter],
});
translateY.value = withDecay({
velocity: velocityY,
clamp: [C.BUBBLES_OFFSET_TOP, height - diameter],
});
},
To indicate that the user can trigger some action by dropping the bubble over the drop area, we can modify its scale and make it slightly bigger.

To achieve this effect, we’ll add some logic to the onActive
method in gestureHandler
. We’ll check if the current position of the dragged bubble on y-axis is below the top of the drop area (that information is passed from BubbleContainer
component via props). If so, we’ll change the shared value of the draggedBubbleScale to be 1.2. If not, it’ll stay or come back to the initial 1. To smooth the animation, we’ll use withTiming
and duration of 200ms.
We cannot forget to pass the transform
prop to BubbleStyle
.
// Bubble.tsx
const draggedBubbleScale = useSharedValue(1);
const gestureHandler = useAnimatedGestureHandler({
//...
onActive: (event, ctx) => {
translateX.value = ctx.offsetX + event.translationX;
translateY.value = ctx.offsetY + event.translationY;
if (translateY.value > dropAreaTop) {
draggedBubbleScale.value = withTiming(1.2, {duration: 200});
} else {
draggedBubbleScale.value = withTiming(1, {duration: 200});
}
},
//...
});
const BubbleStyle = useAnimatedStyle(() => ({
//...
transform: [{scale: draggedBubbleScale.value}],
}));
Save color
Now, that we have a fully responsive animation, it’s time to implement some logic to allow the user to actually save their chosen color.
The idea of this feature is to drag the bubble and drop it in the light grey drop area at the bottom of the screen.
In order for our light grey area to change color and grow to full screen, we modify the drop area from View
to Animated.View
in wrapper component BubbleContainer
:
// BubbleContainer.tsx
const [dropColor, setDropColor] = useState('grey');
// ...
<Animated.View
style={[
styles.dropArea,
{
backgroundColor: dropColor,
left: width / 2 - C.DROP_AREA_INIT_SIZE / 2,
zIndex: dropColor === 'grey' ? 0 : 3,
},
]}
/>
// ...
To implement the logic, we can simply add a conditional statement to onEnd
in gestureHandler
. We check if the current position of the dragged bubble is on top of the drop area and if its scale is bigger than 1 (in this case it should equal 1.2), we trigger handleSelection function.
In order to call a function from JS thread, we need to use runOnJS
from Reanimated
. Read more
// Bubble.tsx
import {runOnJS} from 'react-native-reanimated';
//...
onEnd: ({velocityX, velocityY}) => {
translateX.value = withDecay({
velocity: velocityX,
clamp: [C.BUBBLES_OFFSET_LEFT, width - diameter],
});
translateY.value = withDecay({
velocity: velocityY,
clamp: [C.BUBBLES_OFFSET_TOP, height - diameter],
});
if (translateY.value > dropAreaTop && draggedBubbleScale.value > 1) {
runOnJS(handleSelection)();
}
},
handleSelection
function sets the user’s color and navigates to the previous screen.
// Bubble.tsx
import {useNavigation} from '@react-navigation/native';
import {useUserContext} from '../context/UserContext';
//...
const {setUserColor} = useUserContext();
const {goBack} = useNavigation();
const handleSelection = () => {
setUserColor(color);
goBack()
};
If you’d like to know more about how the navigation in React Native works, please refer to the official documentation or have a look at one of the articles in our blog that covers this topic.
Drop Area Animation
To take our feature one step further, we will animate the drop area (changing the color and growing to full screen). In addition, as a nice touch, we’ll change the selected bubble’s opacity, so it blends nicely.
To change the opacity, we’ll create a shared value bubbleOpacity and set its initial value to 1.
Once the color is selected, the bubble will disappear as we change its opacity to 0.
Feel free to add some additional animations here.
// Bubble.tsx
const bubbleOpacity = useSharedValue(1);
const handleSelection = () => {
setUserColor(color);
bubbleOpacity.value = 0;
goBack()
};
const BubbleStyle = useAnimatedStyle(() => ({
//...
opacity: bubbleOpacity.value,
}));
Let’s focus on the drop area now.
In BubbleContainer
we set shared values for the drop area’s top position and size (height). The trick is to move dropAreaTop from initial bottom offset of 175px (C.DROP_AREA_OFFSET) to 50px above the top of the screen (here C.DROP_AREA_OFFSET_TOP equals -50px) while changing its size simultaneously.
We pass animateDropArea via props to the Bubble component so we can call it inside handleSelection
.
// BubbleContainer.tsx
const dropAreaTop = useSharedValue(height - C.DROP_AREA_OFFSET);
const dropHeight = useSharedValue(C.DROP_AREA_INIT_SIZE);
const animateDropArea = (color) => {
setDropColor(color)
dropAreaTop.value = withTiming(C.DROP_AREA_OFFSET_TOP, {duration: 600});
dropHeight.value = withTiming(1.5 * height, {duration: 600});
};
const animatedDrop = useAnimatedStyle(() => ({
top: dropAreaTop.value,
height: dropHeight.value,
}));
return (
//...
<Animated.View
style={[
styles.dropArea,
animatedDrop,
{
backgroundColor: dropColor,
left: width / 2 - C.DROP_AREA_INIT_SIZE / 2,
zIndex: dropColor === 'grey' ? 0 : 3,
},
]}
/>
{bubbles.map(bubble => (
<View style={{position: 'absolute'}} key={bubble.id}>
<Bubble
{...bubble}
diameter={C.BUBBLE_INIT_SIZE}
dropAreaTop={dropAreaTop.value}
animateDropArea={animateDropArea}
/>
</View>
))}
//...
)
To change the color of the drop area after selection, we call animateDropArea
in handleSelection
function.
We wrap goBack()
with setTimeout()
in order to change the screen just after the animation is completed.
// Bubble.tsx
type BubbleProps = {
color: string;
diameter: number;
position: Position;
dropAreaTop: number;
animateDropArea: (arg0: string) => void;
};
export const Bubble = ({
color,
diameter,
position,
dropAreaTop,
animateDropArea,
}: BubbleProps) => {
//...
const handleSelection = () => {
setUserColor(color);
bubbleOpacity.value = 0;
animateDropArea(color);
setTimeout(() => navigation.goBack(), 800);
};
//...
}
Summary
We’ve reached the end of this article. I hope you’ve found some of the information useful.
You are very welcome to play around with my code. You can clone this repository from GitHub.
React Native Reanimated is a powerful library that gives endless possibilities.

Written by Magda Jaśkowska.