Lately, I’ve been attempting to build/recreate popular UI interactions. One of the more recent ones I’ve built is a swipe-based interaction, similar to the one made popular by the dating app Tinder. It’s a really slick piece of interaction design and is a great example of how an interface can fade into the background. In fact, it removes the interface entirely, leaving only the content itself to interact with. I’d like to walk you through how exactly I did this. or if you prefer, you can skip to the final product
Before we begin, let’s identify our goals:
- choose between boolean values by swiping away a “card”
- use spring-based animations (because springs are so smoooth)
- limit accidental swipes.
- i.e. if the velocity of the swipe is insufficient, the card should return to the stack
Identifying the components
We’ll be building two components to help achieve the goals above. The first, which we’ll call Stack
, will manage the state of the collection of cards as well as act as the bounding box for the swiping. Once a card has crossed its bounds it will provide the details on that card, as well as the value of the swipe (true
or false
).
The second component, is a Card
which will do much of the heavy lifting such as controlling the animation and returning a value for the swipe,
Laying the groundwork
Let’s start off with the basics of both components, starting with Stack
:
//stack.js
import React, { useState } from "react"
import styled from "@emotion/styled"
// basic default styles for container
const Frame = styled(motion.div)`
width: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
position: relative;
`
export const Stack = ({ children, onVote, ...props }) => {
const [stack, setStack] = useState(Children.toArray(children))
return (
<>
<Frame {...props}>
{stack.map((item, index) => {
let isTop = index === stack.length - 1
return (
<Card drag key={item.key || index} onVote={} {...item.props}>
{item}
</Card>
)
})}
</Frame>
</>
)
}
Aside from importing React
we will also be importing useState
and styled
from Emotion. Using emotion
is entirely optional. All of the underlying functionality will be agnostic of any CSS-in-JS framework.
As far as the props go, we have our usual suspects, such as children
, and a catch-all “rest” parameter named ...props
. onVote
will be critical to the functionality of this component, behaving similarly to how an event handler such as onChange
would. Eventually we will wire things up so that whatever function is passed by the onVote prop is triggered when the card leaves the bounds of its parent.
Since the main job of this component is to manage the state of the collection of cards, we will need useState
to help with that. The current state which will be held in the stack
variable, will be initialized with an array representing the children
which have been passed to the component. Since we’ll need to update the stack (via setStack
) each time a card is swiped away, we can’t have this just be a static value.
// stack.js
const [stack, setStack] = useState(Children.toArray(children));
Moving on to the JSX that the component will return:
// stack.js
return (
<>
<Frame {...props}>
{stack.map((child, index) => {
return (
<Card key={child.key || index} onVote={}>
{child}
</Card>
)
})}
</Frame>
</>
)
We’ll map over the stack and return a Card
component for each child in the array. We’ll pass the onVote
prop into each of the cards so it can be triggered at the appropriate time.
Now that we have the basic structure of the Stack
component, we can move on to the Card
, where most of the magic will happen:
// card.js
import React from 'react';
import styled from '@emotion/styled';
const StyledCard = styled.div`
position: absolute;
`;
export const Card = ({ children, style, onVote, id, ...props }) => {
return <StyledCard>{children}</StyledCard>;
};
Our Card
component won’t actually enforce any specific design. It’ll take whatever children are passed to it and wrap it in an absolutely position div (to remove the cards from the flow, and allow them to occupy the same space).
Add some motion
Now we get to the fun part. It’s time to start making our Card
interactive. This is where Framer Motion comes in. Framer Motion is a physics-based animation library in the same vein as React Spring, which I’ve written about before. They are both amazing libraries but Framer absolutely wins-out in terms of which API is simpler to work with (although it could be a little too much “magic” for some people).
Framer Motion provides motion
components for every HTML and SVG element. These components are drop-in replacements for their static counterparts. By replacing our basic (styled) div
with a motion.div
, we gain the ability to use special props to add animations and gesture support to the Card
.
// card.js
import React from 'react';
import { motion } from 'framer-motion';
import styled from '@emotion/styled';
const StyledCard = styled(motion.div)`
position: absolute;
`;
export const Card = ({ children, style, onVote, id, ...props }) => {
return <StyledCard {...props}>{children}</StyledCard>;
};
The first motion prop we’ll be using is the drag
prop. This prop expects a boolean value, and when set to true, allows the element to be dragged around the screen. While most of the motion props will be set directly in our Card
component, this one will be passed to the Card
component from the Stack
:
// stack.js
export const Stack = ({ children, onVote, ...props }) => {
const [stack, setStack] = useState(Children.toArray(children))
return (
<>
<Frame {...props}>
{stack.map((item, index) => {
let isTop = index === stack.length - 1
return (
<Card drag={isTop} key={item.key || index} onVote={}>
{item}
</Card>
)
})}
</Frame>
</>
)
}
You’ll notice that we aren’t setting drag
to a static value of true
. Instead, we are only setting it to true
if the card is at the top of the stack
. This means only the top card will be draggable.
We’ll return back to the Card
component for the remaining motion props:
// card.js
export const Card = ({ children, style, onVote, id, ...props }) => {
// motion stuff
+ const cardElem = useRef(null)
+ const x = useMotionValue(0)
+ const [constrained, setConstrained] = useState(true)
return (
<StyledCard
+ dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
+ dragElastic={1}
+ ref={cardElem}
+ style={{ x }}
+ onDrag={}
+ onDragEnd={}
+ whileTap={{ scale: 1.1 }}
{...props}
>
{children}
</StyledCard>
)
}
Alright, we’ve added quite a bit here. Lets break it down prop by prop
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragConstraints
sets the limits on the draggable area- the values can be set for each side with
left
,top
,right
, andbottom
- the values can be set for each side with
constrained
is not specific to Framer, but is a value we’re going to manage withuseState
.- the contraints will apply only while
contstrained
equalstrue
- the contraints will apply only while
dragElastic={1}
dragElastic
sets the degree of movement allowed outside of the contraints.0
is no movement1
is full movement. The default is0.5
.
ref
is not a motion prop, it is a way to access DOM nodes or React components- it is useful when using APIs such as
getBoundingClientRect
that must be passed a specific DOM node.
- it is useful when using APIs such as
style
is also not a motion prop, it’s just a standard html attribute, but it’s value{ x }
(or{ x: x }
if you prefer) is a varible representing aMotion.Value
(more on that).- in a nutshell, ”
MotionValues
track the state and velocity of animating values.
- in a nutshell, ”
onDrag
is a callback function that fires when the component is draggedonDragEnd
is another callback function which is called then the dragging endswhileTap
allows you to pass properties or variants to animate while the component is tapped (i.e. being dragged)
MotionValues
MotionValue
s are important enough to how Framer Motion works to warrant a closer look.
MotionValue
s can be any string or number. If the MotionValue
contains just one number you can get the velocity of the value via the getVelocity
method. We’ll take advantage of this to determine how fast and in what direction the card is being swiped.
Most of the time you won’t need to worry about creating MotionValues
yourself since they are created automatically by Framer Motion. But in this case, since we want to be able to observe the current value and trigger actions based upon, we’ll need to manually create it. Let’s take another look at how that it done:
// card.js
export const Card = ({ children, style, onVote, id, ...props }) => {
// motion stuff
const cardElem = useRef(null)
const x = useMotionValue(0) // highlight-line
const [constrained, setConstrained] = useState(true)
return (
<StyledCard
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={1}
ref={cardElem}
style={{ x }} // highlight-line
onDrag={}
onDragEnd={}
whileTap={{ scale: 1.1 }}
{...props}
>
{children}
</StyledCard>
)
}
We manually create a MotionValue
by using a custom hook named useMotionValue
, initialize it with a value of 0
, and inject that value into the component via the style
attribute. With our MotionValue
(x
) bound to the value of transform: translate(x)
(which Framer Motion automagically converts x: x
to) we can track changes to the value and respond accordingly.
Determining the Vote
As I mentioned before, a “vote” will be triggered when the Card
leaves the bounds of it’s parent. Specifically we’ll be looking for when the Card
leaves the left or right boundary of the parent.
To accomplish this, we’re add an event listener (Framer provides an onChange
method for this), to the MotionValue
”x
“:
// card.js
export const Card = ({ children, style, onVote, id, ...props }) => {
// motion stuff
const cardElem = useRef(null)
const x = useMotionValue(0)
const [constrained, setConstrained] = useState(true)
+ useEffect(() => {
+ const unsubscribeX = x.onChange(() => {
+ if (cardElem.current) {
const childNode = cardElem.current;
const parentNode = cardElem.current.parentNode;
const result = getVote(childNode, parentNode);
result !== undefined && onVote(result);
}
+ })
+
+ return () => unsubscribeX()
+ })
return (
<StyledCard
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={1}
ref={cardElem}
style={{ x }}
onDrag={}
onDragEnd={}
whileTap={{ scale: 1.1 }}
{...props}
>
{children}
</StyledCard>
)
}
As the documentation states: ”onChange
returns an unsubscribe method, so it works quite naturally with useEffect
”. Meaning this should be returned from the useEffect
function in order to prevent adding duplicate subscribers.
As far as the code that will be triggered by the event handler, we are calling a getVote()
function and passing two arguments: the DOM node for the Card
(cardElem.current
) and the parent node of the Card
(cardElem.current.parentNode
):
// card.js
export const Card = ({ children, style, onVote, id, ...props }) => {
// motion stuff
const cardElem = useRef(null)
const x = useMotionValue(0)
+ const [vote, setVote] = useState(undefined)
const [constrained, setConstrained] = useState(true)
+ const getVote = (childNode, parentNode) => {
+ const childRect = childNode.getBoundingClientRect()
+ const parentRect = parentNode.getBoundingClientRect()
+ let result =
+ parentRect.left >= childRect.right
+ ? false
+ : parentRect.right <= childRect.left
+ ? true
+ : undefined
+ return result
+ }
useEffect(() => {
const unsubscribeX = x.onChange(() => {
if (cardElem.current) {
const childNode = cardElem.current;
const parentNode = cardElem.current.parentNode;
const result = getVote(childNode, parentNode);
result !== undefined && onVote(result);
}
})
return () => unsubscribeX()
})
return (
<StyledCard
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={1}
ref={cardElem}
style={{ x }}
onDrag={}
onDragEnd={}
whileTap={{ scale: 1.1 }}
{...props}
>
{children}
</StyledCard>
)
}
getVote
then takes those two nodes and gets their outer bounds via the .getBoundingClientRect()
method (more info). By comparing the opposite boundaries of the parent and child components, we can determine when the child has left its parent. If the parent’s left boundary is greater than or equal to the child’s right boundary, the function returns false
. Or if the opposite is true, returns true
.
// card.js
useEffect(() => {
const unsubscribeX = x.onChange(() => {
if (cardElem.current) {
const childNode = cardElem.current;
const parentNode = cardElem.current.parentNode;
const result = getVote(childNode, parentNode); //highlight-line
result !== undefined && onVote(result); // highlight-line
}
});
return () => unsubscribeX();
});
Once getVote
returns something other than undefined
the function passed in the onVote
prop is invoked, receiving the result
as an argument.
Jumping back to the Stack
component, we can define what comes next. We can start with the onVote
prop:
// stack.js
export const Stack = ({ onVote, children, ...props }) => {
const [stack, setStack] = useState(Children.toArray(children))
+ // return new array with last item removed
+ const pop = (array) => {
+ return array.filter((_, index) => {
+ return index < array.length - 1
+ })
+ }
+ const handleVote = (item, vote) => {
+ // update the stack
+ let newStack = pop(stack)
+ setStack(newStack)
+
+ // run function from onVote prop, passing the current item and value of vote
+ onVote(item, vote)
+ }
return (
<>
<Frame {...props}>
{stack.map((item, index) => {
let isTop = index === stack.length - 1
return (
<Card
drag={isTop} // Only top card is draggable
key={item.key || index}
+ onVote={(result) => handleVote(item, result)}
>
{item}
</Card>
)
})}
</Frame>
</>
)
}
Knowing that the Card
will pass the result of the vote, we can continue to pass the result to the Stack
’s handleVote
function along with the current card (item
). The handleVote
function will handle all of the side-effects, such as removing the top card from the stack (by removing the last item in the array) and invoking the function passed to the onVote
prop.
// stack.js
export const Stack = ({ onVote, children, ...props }) => {
const [stack, setStack] = useState(Children.toArray(children));
// return new array with last item removed
const pop = (array) => {
return array.filter((_, index) => {
return index < array.length - 1;
});
};
const handleVote = (item, vote) => {
// update the stack
let newStack = pop(stack);
setStack(newStack);
// run function from onVote prop, passing the current item and value of vote
onVote(item, vote);
};
return (
<>
<Frame {...props}>
{stack.map((item, index) => {
let isTop = index === stack.length - 1;
return (
<Card
drag={isTop} // Only top card is draggable
key={item.key || index}
onVote={(result) => handleVote(item, result)}
>
{item}
</Card>
);
})}
</Frame>
</>
);
};
And with that, the Stack
component is finished. Now all that is left is refine behavior of the Card
component.
Functionally the Card
component is complete, but there is one major issue. You can’t just flick the card away. It needs to be dragged the whole way. This could be addressed by removing the dragConstraints
but that would mean the card would not return to the stack if the swipe was aborted, leaving the cards beneath exposed and unable to interact.
A better solution (and one that will provide a more familiar experience to users) is to set a minimum threshold for the cards velocity. If at the end of a swipe the velocity is above that threshold (i.e. the escape velocity), the card will continue off at it’s current trajectory on its own.
Now how do we do this?
// card.js
export const Card = ({ children, style, onVote, id, ...props }) => {
// motion stuff
const cardElem = useRef(null)
const x = useMotionValue(0)
+ const controls = useAnimation()
const [vote, setVote] = useState(undefined)
const [constrained, setConstrained] = useState(true)
+ const [direction, setDirection] = useState()
+ const [velocity, setVelocity] = useState()
const getVote = (childNode, parentNode) => {
const childRect = childNode.getBoundingClientRect()
const parentRect = parentNode.getBoundingClientRect()
let result =
parentRect.left >= childRect.right
? false
: parentRect.right <= childRect.left
? true
: undefined
return result
}
+ // determine direction of swipe based on velocity
+ const getDirection = () => {
+ return velocity >= 1 ? "right" : velocity <= -1 ? "left" : undefined
+ }
+ const getTrajectory = () => {
+ setVelocity(x.getVelocity())
+ setDirection(getDirection())
+ }
+ const flyAway = (min) => {
+ const flyAwayDistance = (direction) => {
+ const parentWidth = cardElem.current.parentNode.getBoundingClientRect()
+ .width
+ const childWidth = cardElem.current.getBoundingClientRect().width
+ return direction === "left"
+ ? -parentWidth / 2 - childWidth / 2
+ : parentWidth / 2 + childWidth / 2
+ }
+ if (direction && Math.abs(velocity) > min) {
+ setConstrained(false)
+ controls.start({
+ x: flyAwayDistance(direction)
+ })
+ }
+ }
useEffect(() => {
const unsubscribeX = x.onChange(() => {
if (cardElem.current) {
const childNode = cardElem.current;
const parentNode = cardElem.current.parentNode;
const result = getVote(childNode, parentNode);
result !== undefined && onVote(result);
}
})
return () => unsubscribeX()
})
return (
<StyledCard
+ animate={controls}
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={1}
ref={cardElem}
style={{ x }}
+ onDrag={getTrajectory}
+ onDragEnd={() => flyAway(500)}
whileTap={{ scale: 1.1 }}
{...props}
>
{children}
</StyledCard>
)
}
I’m kind of throw a lot at you at once here, but bare with me. The most important change to note here is the addition of passing getTrajectory
to the onDrag
prop and flyAway
to onDragEnd
. Both functions do pretty much exactly what they sound like they do. setTrajectory
will determine the trajectory (including velocity) of the Card
’s movement. Once the dragging has ended flyAway
will determine if the final velocity is high enough for the the Card
to break free and fly out of bounds without constraint.
Since onDragEnd
is invoked after onDrag
it makes sense to first explore the latter.
onDrag
As we covered earlier, onDrag
is a callback function that fires when the component is dragged. Our function getTrajectory
doesn’t really do anything noticeable to a user. what it does is update/track the current state of the component. This state will ultimately be used by the flyAway
function to determine what, if anything, it should do.
// card.js
// determine direction of swipe based on velocity
const getDirection = () => {
return velocity >= 1 ? 'right' : velocity <= -1 ? 'left' : undefined;
};
const getTrajectory = () => {
setVelocity(x.getVelocity());
setDirection(getDirection());
};
getTrajectory
is really just a wrapper for a couple of other functions. The first, setVelocity
is the usetState
function for a useState
hook. While the Card
is being dragged the velocity
state is constantly updated. I would have preferred to only check the latest velocity on drag end, but unfortunately the velocity on drag end is always 0
. The second function, setDirection
is part of another useState
hook. The possible values returned by getDirection
are "left"
and "right"
which is determined based on whether velocity
is a positive or negative number.
onDragEnd
The finish line is now in sight. The last major piece of functionality left to examine is our onDragEnd
callback function flyAway
.
// card.js
const flyAway = (min) => {
const flyAwayDistance = (direction) => {
const parentWidth = cardElem.current.parentNode.getBoundingClientRect().width;
const childWidth = cardElem.current.getBoundingClientRect().width;
return direction === 'left'
? -parentWidth / 2 - childWidth / 2
: parentWidth / 2 + childWidth / 2;
};
if (direction && Math.abs(velocity) > min) {
setConstrained(false);
controls.start({
x: flyAwayDistance(direction)
});
}
};
We can skip over the flyAwayDistance
function for now since we won’t need that until later. The important to notice here is that the flyAway
function doesn’t actually do anything unless a direction
has been set and the velocity
is greater than the min
(minimum) value we passed it as an argument. Once that criteria has been met we invoke one final setState
function called setConstrained
and pass it a value of false and invoke the start
method on controls
.
// card.js
const controls = useAnimation();
controls
is an instance of the useAnimation
hook from Framer Motion. This hook enables you “to create a set of imperative AnimationControls with a start and stop method”. These controls are passed to the component via the animate
prop
// card.js
return (
<StyledCard
animate={controls} // highlight-line
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={1}
ref={cardElem}
style={{ x }}
onDrag={getTrajectory}
onDragEnd={() => flyAway(500)}
{...props}
>
{children}
</StyledCard>
);
When the start
method is invoked, the object that is passed describes the animation. In this case, we are setting x
to whatever value is returned from flyAwayDistance
.
// card.js
const flyAwayDistance = (direction) => {
const parentWidth = cardElem.current.parentNode.getBoundingClientRect().width;
const childWidth = cardElem.current.getBoundingClientRect().width;
return direction === 'left'
? -parentWidth / 2 - childWidth / 2
: parentWidth / 2 + childWidth / 2;
};
In order to calculate the distance, we need to know how wide the parent element is. The first step to finding the parent is to find the child. cardElem.current
will get us the DOM node for the Card
(i.e. the child). We then get the parent node with… wait for it… parentNode
. The getBoundingClientRect()
method will return a handful of attributes about the nodes size and position (left
, top
, right
, bottom
, x
, y
, width
, and height
). The only one we care about is width
. With this value and the direction
parameter in hand, we can do a little math to figure out how far our Card
needs to fly.
Finishing Touches
With that, our components are all but finished. Everything is fully functional at this point, but lets take an extra couple of minutes to shine this thing up.
One small UX enhancement we can make is to add a whileTap
state. There really isn’t much to it:
// card.js
return (
<StyledCard
animate={controls} // highlight-line
dragConstraints={constrained && { left: 0, right: 0, top: 0, bottom: 0 }}
dragElastic={1}
ref={cardElem}
style={{ x }}
onDrag={getTrajectory}
onDragEnd={() => flyAway(500)}
+ whileTap={{ scale: 1.1 }}
{...props}
>
{children}
</StyledCard>
)
The whleTap
prop accepts an animation target that is applied only while the component is being tapped/dragged.
Usage
In order to use our Card
component all we need to do is wrap a group of elements in our Stack
component, the Stack
component will handle the rest.
We can also add some styles to those elements to make things look nice:
// app.js
import React from 'react';
import './styles.css';
import { Stack } from './components/stack';
import styled from '@emotion/styled';
export default function App() {
const Wrapper = styled(Stack)`
background: #1f2937;
`;
const Item = styled.div`
background: #f9fafb;
width: 200px;
height: 250px;
display: flex;
align-items: center;
justify-content: center;
font-size: 80px;
text-shadow: 0 10px 10px #d1d5db;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
border-radius: 8px;
transform: ${() => {
let rotation = Math.random() * (5 - -5) + -5;
return `rotate(${rotation}deg)`;
}};
`;
return (
<div className="App">
<Wrapper onVote={(item, vote) => console.log(item.props, vote)}>
<Item data-value="waffles" whileTap={{ scale: 1.15 }}>
🧇
</Item>
<Item data-value="pancakes" whileTap={{ scale: 1.15 }}>
🥞
</Item>
<Item data-value="donuts" whileTap={{ scale: 1.15 }}>
🍩
</Item>
</Wrapper>
</div>
);
}
The Final Product
To see all that we’ve built here in action take a look at the CodeSandbox below: