Skip to main content Logo CHRISBERRY.io

Tinder Style Swipe With Framer Motion

Categories

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, and bottom
    • constrained is not specific to Framer, but is a value we’re going to manage with useState.
      • the contraints will apply only while contstrained equals true
  • dragElastic={1}
    • dragElastic sets the degree of movement allowed outside of the contraints. 0 is no movement 1 is full movement. The default is 0.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.
  • 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 a Motion.Value (more on that).
    • in a nutshell, ”MotionValues track the state and velocity of animating values.
  • onDrag is a callback function that fires when the component is dragged
  • onDragEnd is another callback function which is called then the dragging ends
  • whileTap allows you to pass properties or variants to animate while the component is tapped (i.e. being dragged)

MotionValues

MotionValues are important enough to how Framer Motion works to warrant a closer look.

MotionValues 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 MotionValuex“:

// 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: