The Widlarz Group Blog
Recreating Quibi's cards swipe animation with React Native and Reanimated
February 17, 2021
- Introduction
- Before we start - how our animation should work
- Managing swipe gestures
- Writing the animation logic
Introduction
Some time ago, a new video streaming app arose. Among services like Netflix, HBO GO or Amazon Prime, we also got a chance to consume tv series and movies on another platform called Quibi. The idea of it is slightly different though - app allows us to watch mainly original productions that can be viewed in either horizontal position or PORTRAIT! Depending on our choice, the app would serve us differently cut content.
The idea was suppossed to be revolutionary, but in the end, it did not make a huge success. Nevertheless, the mobile app itself has a nice card swiping animation that we will try to recreate with React Native
and Reanimated
in this article!
Below, a little preview of the original app and the one that we will build in this article:
As you can spot, the cards in the stack, apart from moving up and down, are also scaling down and snap to the center of the screen. It is really an interesting alternative to just a simple <FlatList />
with some items. Such features really enrichen the user experience.
We will focus mainly on an animation itself, not on the styling. That is why I created a starter project with some mock movie data and basic styled components. You can work along using this starter repo on GitHub.
Before we start - how our animation should work
Shall we start? 😎
So before we write a first line of code, let’s quickly plan our work and put down all the things we need to do in points below:
-
At the very beginning, we need to handle the gesture events like swiping and touching
-
Then, we can start working on animating the cards up and down. Every swipe would both move the stack and simultaneously scale down the previous card.
-
Each finished or cancelled touch gesture would then trigger either the current card change or snap the whole stack to the center of the screen in such a way, that the active card is fully visible and centered. For this to work nicely, we will declare a few constraints to help us trigger such actions - velocity of the swipe, state of the swipe and the swiped distance.
Managing swipe gestures
In order to handle the gesture events, we will of course use react-native-gesture-handler for handling it.
Let’s wrap a <FlatList />
with <PanGestureHandler />
in our HomeScreen
and let’s also assign the gesture events data to the Animated.Values
with Animated.event
helper, just like so:
/src/screens/HomeScreen.tsx
import {
FlatList,
PanGestureHandler,
State,
} from "react-native-gesture-handler"
...
const HomeScreen = () => {
...
const translationY = useRef<Animated.Value<number>>(new Value(0)).current;
const state = useRef<Animated.Value<State>>(new Value(State.UNDETERMINED))
.current;
const velocityY = useRef<Animated.Value<number>>(new Value(0)).current;
const onGesture = event([
{
nativeEvent: {
translationY,
state,
velocityY,
},
},
]);
...
Let’s also pass the onGesture
to <PanGestureHandler />
with which we wrap our list:
/src/screens/HomeScreen.tsx
...
return (
<View style={styles.container}>
<Header />
<PanGestureHandler
onGestureEvent={onGesture}
onHandlerStateChange={onGesture}>
<Animated.View
style={styles.contentContainer}
onLayout={({nativeEvent: {layout}}) => setCardHeight(layout.height)}>
<FlatList
contentContainerStyle={styles.contentContainer}
removeClippedSubviews={false}
data={cardsList}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
keyExtractor={(item) => item.id}
renderItem={({item: card}) => {
return (
<Animated.View
style={StyleSheet.flatten([
styles.cardWrapper,
{
height: cardHeight,
},
])}
key={card.id}>
<Card {...card} />
</Animated.View>
);
}}
/>
</Animated.View>
</PanGestureHandler>
</View>
);
}
We additionaly put our <FlatList />
inside <Animated.View />
and moved the onLayout
to aforementioned <Animated View />
. A reason for this is that PanGestureHandler
requires all of its child to be of Animated kind.
With this being done, we now have the access to the translation value on Y axis for our swipe list, its velocity and its state, telling us whether the swipe has started, is ongoing, finished or maybe cancelled.
Done! First part of coding is behind us and now we can go on and focus on the animation logic.
Writing the animation logic
As you might probably know, in order to animate anything with react-native-reanimated
(at least with its API v1), we have to create a function that handles all the logic and returns an animated node. The logic must be coded with a special set of helper functions, e.g. instead of a classic if-else
block, we have to use the cond
helper function.
Feel free to study the official documentation to browse through all of them here
Now, let’s make a new file in which we will declare our animation. We can put it in a separate folder in our /src
directory. Inside, create the function which should take in all the data that we previously tried to assign to animated values (translation data, velocity and state). It should also consume information about the card height which is different for every screen obviously 👨🏽🏫 and contain the state
and config
configuration objects (explained below).
Code:
/src/animations/cardSwipe.ts
import Animated, {block} from 'react-native-reanimated'
import {State} from 'react-native-gesture-handler'
const {Value} = Animated
export const cardSwipe = (
translationY: Animated.Value<number>,
dragState: Animated.Value<State>,
velocityY: Animated.Value<number>,
cardHeight: number,
) => {
const state = {
finished: new Value(0),
velocity: new Value(0),
position: new Value(0),
time: new Value(0),
}
const config = {
damping: 12,
tension: 1,
friction: 2,
mass: 0.2,
stiffness: 121.6,
overshootClamping: false,
restSpeedThreshold: 0.001,
restDisplacementThreshold: 0.001,
toValue: new Value(0),
}
return block([translationY])
}
As mentioned before, inside the body of this function we declared two objects - state
and config
. The former represents the current state of the animation (its progress, whether it has finished etc) and the latter is simply (as the name suggests) a config object that defines how the animation is going to behave. In this case, it is the config for a spring
type of animation.
And last but not least is the return block. It returns the translationY
value for now, so it actually isn’t doing anything. At least yet! 😂 With a little bit of patience, we will create a really nice animation in a matter of minutes! 🏃🏽♀️
But before we go any further, let’s think for a while. Right now, we don’t know what is the current active card, so we would need its index number as well. We also don’t know to what moment should we allow our swipe to be active - we want to lock the swiping at some point, for instance when we reach the end of the list. For this to happen, we have to add two more parameters - index
and maxIndex
which would be of a number type.
Apart from it, we also need a Clock
for our animation to work. It is how the animations in reanimated
work (Reanimated docs: Clock).
/src/animations/cardSwipe.ts
...
export const cardSwipe = (
translationY: Animated.Value<number>,
dragState: Animated.Value<State>,
velocityY: Animated.Value<number>,
activeIndex: Animated.Value<number>,
maxIndex: number,
clock: Animated.Clock,
cardHeight: number,
) => {
...
Let’s go ahead now and import this function in our <HomeScreen />
component, invoke and pass it to a variable. We also need to create another set of Animated.Values
for an aforementioned index
value and an Animated.Clock
:
/src/screens/HomeScreen.tsx
import {cardSwipe} from '../animations/cardSwipe';
...
const [cardHeight, setCardHeight] = useState(0);
const translationY = useRef<Animated.Value<number>>(new Value(0)).current;
const state = useRef<Animated.Value<State>>(new Value(State.UNDETERMINED))
.current;
const velocityY = useRef<Animated.Value<number>>(new Value(0)).current;
const clock = useRef<Animated.Clock>(new Clock()).current;
const activeCardIndex = useRef<Animated.Value<number>>(new Value(0)).current;
const cardAnimation = useMemo(
() =>
cardSwipe(
translationY,
state,
velocityY,
activeCardIndex,
cardsList.length - 1,
clock,
cardHeight,
),
[cardsList, cardHeight],
);
...
You may ask why do we use
useRef
hook to set these values and not just simply assigning them to a variable. It is because each component’s re-render would re-assign them, thus what follows - reset our whole animation. We don’t want such thing to happen. A good practise with these values and a clock is to store them with e.g.useRef
or even withuseState
(const [value] = useState(new Animated.Value(0))). We additionally wrap thecardSwipe
invoke withuseMemo
, as we don’t want to redeclare it on every rerender as well (only when a certain value change). Don’t worry, the e.g.translationY
orveloctyY
won’t re-memoize the useMemo as these values are just instances of the animated node, they are decalared once, on component mount.
Having done this, we can now go back to our animating function. Let’s first tackle the easiest part, which would be the swipe handling:
/src/animations/cardSwipe.ts
...
return block([
cond(
or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
[stopClock(clock), add(translationY, state.position)],
[translationY],
),
]);
...
Our condition statement checks whether the touch event is ongoing. We do it with or
and eq
utils. If this node evaluates to some truthy value, the second node will be evaluated and return a given value. Otherwise, the third node will be triggered. You can take a peek at the docs - Reanimated: cond - to get some more thorough knowledge on it 💥.
As for the second node itself, it simply stops the clock being a representation of our animation and then adds the current translationY
value to the animation state (state.position
). When add
is evaluated, it returns the summed value (think of it as some kind of context).
The third node on the other hand, returns nothing but the translationY
value. It is not the final solution as we want to add some logic here which is supposed to run some checks and configure the animation - whether we should animate back to the initial card position or maybe go up or down in the stack of the cards. But for now, let’s not focus on it and first go back to our <HomeScreen />
component and connect our simple animation to the actuall View
. With the setup that we currently have, it should only move upwards or downwards.
Let’s look at our <FlatList />
and put some code in there:
/src/screens/HomeScreen.tsx
<FlatList
contentContainerStyle={styles.contentContainer}
...
renderItem={({item: card}) => {
return (
<Animated.View
style={StyleSheet.flatten([
styles.cardWrapper,
{
height: cardHeight,
},
{
transform: [{translateY: cardAnimation}],
},
])}
key={card.id}>
<Card {...card} />
</Animated.View>
);
}}
/>
So what we did was just adding the transform
style property to the <Animated.View />
.
If we were to run the app now and try to swipe up and down, this is what we would see inside our emulator or physical phone:
Now, it would make sense to tackle the interpolation of other style -> scale
. As in the original app, we want our current card in the stack to stay in place and scale down when swiping up or move down when swiping downwards (a default-ish behavior in fact). As for the first card in the stack, it should do nothing for now (when swiping upwards, that is 🤪).
Let’s first update the StyleSheet
for our cards and change their position to absolute
and update the tranfrom
property one more time:
/src/screens/HomeScreen.tsx
const styles = StyleSheet.create({
...
cardWrapper: {
padding: PADDING,
width: '100%',
position: 'absolute', // <- added
top: 0, // <- added
left: 0, // <- added
},
});
...
Let’s also update our swiping behaviour for each card that we render. For this, let’s also go back to the <FlatList />
again:
/src/screens/HomeScreen.tsx
<FlatList
...
renderItem={({item: card, index}) => {
const position = index * -cardHeight;
const nextPosition = (index + 1) * -cardHeight;
const prevPosition = (index - 1) * -cardHeight;
const firstItemSwipeDownTranslationBlock =
index === 0 ? 50 : prevPosition;
const translateY = interpolate(cardAnimation, {
inputRange: [nextPosition, position, prevPosition],
outputRange: [
position,
position,
firstItemSwipeDownTranslationBlock,
],
});
const scale = interpolate(cardAnimation, {
inputRange: [nextPosition, position, prevPosition],
outputRange: [0.8, 1, 1],
extrapolate: Extrapolate.CLAMP,
});
return (
<Animated.View
style={StyleSheet.flatten([
styles.cardWrapper,
{
height: cardHeight,
},
{
transform: [
{translateY: add(-position, translateY)},
{scale},
],
},
])}
key={card.id}>
<Card {...card} />
</Animated.View>
);
}}
/>
...
We put quite a bit of code above. Let’s get over each line and try to explain what is actually happening in there.
What has changed? We added the scale
value which is the outcome of the interpolation
. We also changed the translateY
transform style property to a new value - a sum of the position
variable and translateY
variable.
With our card wrapper styling changed to absolute
position, each card in the stack is in the same position, each put on top of the other.
By simply adding the position
(which is the cardHeight
multiplied by the index of the card) to the new translateY
value, the cards are again located one below the other, not stacked on top of each other.
As cards position is already fixed, we can now block each previous card from moving upwards and instead staying in place and scaling down (with a bit of help from interpolation
).
To help you understand, here’s a simple graphic:
Let’s assume that the current card in the stack is the fourth one (index of 3). Let’s also assume that the height of each card is 100px.
In such case, the current position of the card would be 300px (from the top in Y axis). Going further by swiping down, the card would start moving towards 400px and towards the 200px in the opposite direcion. The latter we want to block though. That is why we declare position
, prevPosition
and nextPosition
for each rendered card. This way we can use this data to interpolate the animated value.
For every incoming position
, we output the same value. Going further, we output the same value for every incoming one that is closer to the nextPosition
. But for every value that is going towards prevPostion
, starting from position
, we want to leave the same value as initial one - position
.
Hope that it makes any sense 🤭.
Similarily, we can do the same with the scale. The difference would be only the outputRange
in our interpolation helper:
Thanks to this interpolation
, we can map a certain range of values to a completely different one. How it works? Let’s also say we animate from 0
to 1
. Then, we could transform this range to e.g. 100
- 50
. When at the start of an animation, the value is equal 0, the interpolation will actually output 100. When at 0.5 for example, the interpolation would output 50. I hope you can get the idea.
And mapping of ranges is not stopped nor blocked at the ends of a given range, but goes beyond keeping the proper ratio. We can change this though, by setting the Extrapolation
value in the interpolation settings (e.g. CLAMP
, meaning that the values that exceeds the range of the input or are below the range, will stay the same.).
For scale
in our styles, we additionally added this extrapolation
property so that the values will clamp and won’t go any further than they are supposed to.
There is one more minor thing that we did:
const firstItemSwipeDownTranslationBlock = index === 0 ? 50 : prevPosition
In here, we are just exchanging the prevPosition
of the first card in the stack to some smaller value so we could get this slower-ish kind of a swipe.
Let’s see how everything works and feels like in the simulator now:
Great progress! The next thing on our todo list would be the snapping and current card changing.
So far, our code in the return block looks like this:
/src/animations/cardSwipe.ts
return block([
cond(
or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
[stopClock(clock), add(translationY, state.position)],
[translationY],
),
])
Let’s remove the translationY
and instead do this:
return block([
cond(
or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
[stopClock(clock), add(translationY, state.position)],
[
cond(clockRunning(clock), 0, [
set(state.finished, 0),
set(state.time, 0),
set(state.velocity, velocityY),
set(state.position, add(translationY, state.position)),
]),
],
),
])
The code that we added resets the animation by updating some of the state
properties to be falsy. It also sets the velocity
and the initial position
for our animation which is exactly the same as in the if
node (second parameter in the cond
function). And finally, all this stuff should be fired up only when the clock
is not currently running.
Another thing would be to update the activeIndex
of the cards based on the velocity
and the translationY
value. Let’s code it and assign the threshold
that we will use in our logic to a variable outside of the scope of our animating function and set it to, let’s also say 500
(feel free to mess around with the settings to your liking if you want 😎):
/src/animations/cardSwipe.ts
...
const VELOCITY_TRESHOLD = 500;
export const cardSwipe = (
translationY: Animated.Value<number>,
dragState: Animated.Value<State>,
...
Let’s also handle a case for increasing the index of the current card. The important thing here would be the constraints for the setter - as stated above, we should check the velocity
and translateY
values and check them against this previously defined threshold value and the card height. We also have to check if the current value of the index is less than the index of the last card in the stack.
Code:
/src/animations/cardSwipe.ts
cond(
or(
and(
lessThan(activeIndex, maxIndex),
lessThan(translationY, -cardHeight * 0.25),
),
and(
lessThan(activeIndex, maxIndex),
lessThan(velocityY, -VELOCITY_THRESHOLD),
),
),
[
set(activeIndex, add(activeIndex, 1)),
],
),
Notice that we used some reanimated
util functions to evaluate if the value is less or greater than the other or to do addition. If the active card index is less than the maximum index value AND either the velocity of the swipe or the scrolled distance exceeds set thresholds, we can evaluate the node passed as the second argument to the cond
util. This node task is to increase the activeIndex by one (set
for setting the value and add
to add one to the passed value).
Also, please notice that we have to take into consideration the fact, that the velocity might be a negative number 🙃.
Another condition block should cover the activeIndex
decrease (with sub
util instead of the add
and greaterThan
instead of lessThan
) which is pretty similar to the previous one:
/src/animations/cardSwipe.ts
cond(
or(
and(
greaterThan(activeIndex, 0),
greaterThan(translationY, cardHeight * 0.25),
),
and(
greaterThan(activeIndex, 0),
greaterThan(velocityY, VELOCITY_THRESHOLD),
),
),
[
set(activeIndex, sub(activeIndex, 1)),
],
),
Now, that our two blocks are ready, we need to fire them up only when the touch gesture is in the END
state. Otherwise, the animation would be firing up endlessly over and over again in a loop.
To embelish the user experince, we can also add another constraint of 50 pixels threshold for the translationY
value.
Let’s wrap our two condition blocks then, just like so:
/src/animations/cardSwipe.ts
cond(
and(eq(dragState, State.END), greaterThan(abs(translationY), 50)),
[
cond(
or(
and(
lessThan(activeIndex, maxIndex),
lessThan(translationY, -cardHeight * 0.25),
),
and(
lessThan(activeIndex, maxIndex),
lessThan(velocityY, -VELOCITY_THRESHOLD),
),
),
[set(activeIndex, add(activeIndex, 1))],
),
cond(
or(
and(
greaterThan(activeIndex, 0),
greaterThan(translationY, cardHeight * 0.25),
),
and(
greaterThan(activeIndex, 0),
greaterThan(velocityY, VELOCITY_THRESHOLD),
),
),
[set(activeIndex, sub(activeIndex, 1))],
),
],
),
translationY
is wrapped withabs
helper as we want to check whether it exceeds the threshold in both directions.
After handling our setters and having the value of activeIndex
updated, we can now set the destination value of our animation, being simply a multiplied card height by the value of (yes, you guessed it) activeIndex
. Put the code responsible for it just below our block with the setters:
/src/animations/cardSwipe.ts
set(config.toValue, multiply(activeIndex, -cardHeight)),
cardHeight
must be a negative number, otherwise the animation would go in reverse 😎
We are now getting closer and closer to achieving the final scroll animation. What we still got to do is:
- Starting the animation by firing up the clock and passing it to the
spring
function - Reseting the
velocityY
andtranslationY
values - Stopping the clock after the animation is finished and returning the position of the animation
As for the first point:
/src/animations/cardSwipe.ts
cond(
or(
eq(dragState, State.UNDETERMINED),
greaterThan(abs(translationY), 0),
),
[startClock(clock)],
),
A clock should be started whenever we swipe, thus greaterThan(abs(translationY), 0)
.
If we were to start the animation at the very first render (e.g. if we would want to animate for a given index for some reason), we would want this condition to pass:
eq(dragState, State.UNDETERMINED)
. For purposes of this article we basically don’t need it. Feel free to delete if you do not need it in your use case 🤖
Let’s also reset the aforementioned values (second point):
/src/animations/cardSwipe.ts
set(translationY, 0),
set(velocityY, 0),
and stop the clock along with returning a state.position
(third point):
/src/animations/cardSwipe.ts
spring(clock, state, config),
cond(state.finished, stopClock(clock)),
state.position,
Our finished animation logic should look like this 👨🏽🏫:
/src/animations/cardSwipe.ts
const VELOCITY_THRESHOLD = 500
export const cardSwipe = (
translationY: Animated.Value<number>,
dragState: Animated.Value<State>,
velocityY: Animated.Value<number>,
activeIndex: Animated.Value<number>,
maxIndex: number,
clock: Animated.Clock,
cardHeight: number,
) => {
const state = {
finished: new Value(0),
velocity: new Value(0),
position: new Value(0),
time: new Value(0),
}
const config = {
damping: 12,
tension: 1,
friction: 2,
mass: 0.2,
stiffness: 121.6,
overshootClamping: false,
restSpeedThreshold: 0.001,
restDisplacementThreshold: 0.001,
toValue: new Value(0),
}
return block([
cond(
or(eq(dragState, State.ACTIVE), eq(dragState, State.BEGAN)),
[stopClock(clock), add(translationY, state.position)],
[
cond(clockRunning(clock), 0, [
set(state.finished, 0),
set(state.time, 0),
set(state.velocity, velocityY),
set(state.position, add(translationY, state.position)),
cond(
and(eq(dragState, State.END), greaterThan(abs(translationY), 50)),
[
cond(
or(
and(
lessThan(activeIndex, maxIndex),
lessThan(translationY, -cardHeight * 0.25),
),
and(
lessThan(activeIndex, maxIndex),
lessThan(velocityY, -VELOCITY_THRESHOLD),
),
),
[set(activeIndex, add(activeIndex, 1))],
),
cond(
or(
and(
greaterThan(activeIndex, 0),
greaterThan(translationY, cardHeight * 0.25),
),
and(
greaterThan(activeIndex, 0),
greaterThan(velocityY, VELOCITY_THRESHOLD),
),
),
[set(activeIndex, sub(activeIndex, 1))],
),
],
),
set(config.toValue, multiply(activeIndex, -cardHeight)),
cond(
or(
eq(dragState, State.UNDETERMINED),
greaterThan(abs(translationY), 0),
),
[startClock(clock)],
),
set(translationY, 0),
set(velocityY, 0),
]),
spring(clock, state, config),
cond(state.finished, stopClock(clock)),
state.position,
],
),
])
}
Let’s save our progess, restart the app and check how it looks in the simulator/on the phone 🔥:
You can find the final code for it in the repo here!
I hope that you found this article at least a bit helpful and learned a bit about the reanimated
library. Hopefully it was fun 👨🏽🏫
Feel free to re-use some of the code and if you fancy some more reading on the topic of animations in React Native, then check out the rest of our blog 👋🏽

Written by Daniel Grychtoł.