Framer Motion Draggable Pip

Jan 16, 2021

How to make a draggable picture-in-picture component with Framer Motion

This post assumes a basic knowledge of CSS, React, and React hooks, particularly useRef

I've been really loving the Framer Motion animation library for React lately. Granted, sometimes the docs are a bit frustrating if you don't already have a decent understanding about animations. But! I've found that I usually only run into those frustrations when trying to get a bit more complex. If you're looking for a way to bootstrap your animations so you don't have to write custom CSS keyframes all the time, Framer Motion is beautiful and easy-to-use out of the box.

Inspiration

A few weeks ago, I saw a tweet from Alex Anderson about a draggable pip, or picture-in-picture, React hook he made using React Spring!

Inspired, I decided to see if I could replicate the functionality using Framer Motion.

Demo

Drag the gif around to see the final product in action or check out the final code on CodeSandbox!

PipJS

Drag the gif!

Toddler Korra bending water, earth, and fire

Structure

The setup is pretty barebones! There's a containing App component and then the Pip component as a child of App.

App.jsJS
1import React from "react";
2import Pip from "./components/Pip";
3
4export default function App() {
5 const appRef = React.useRef(null);
6
7 return (
8 <div
9 ref={appRef}
10 style={{
11 height: '100vh',
12 width: '100%',
13 position: 'relative'
14 }}
15 >
16 <Pip appRef={appRef} />
17 </div>
18 );
19}

In the actual CodeSandbox, I put all of the styling in a .css file. But for ease of understanding here, I pulled them in to use in the style prop.

Basic structure and styling

The actual JSX structure of this component is pretty minimal! Just an img inside of a container div. The img tag is styled to take up the full height and width of its container, which I've given explicit dimensions. I've positioned the component absolutely to be in the top-left corner of its parent, the parent div in App.js (since I've positioned that element relatively). You'll also notice I set the pointer-events on the img to "none". This is so that when you click and drag its containing div, it will actually move the image around the screen instead of the default behavior when you try to drag an img component.

Pip.jsJS
1<div
2 style={{
3 position: 'absolute',
4 top: 0,
5 left: 0,
6 width: '341.5px', /* maintain aspect ratio of dimensions of this original img source */
7 height: '255px'
8 }}
9>
10 <img
11 alt="Toddler Korra bending water, earth, and fire"
12 src="https://media1.tenor.com/images/e3ee9db7e7c1a339e2006670c51b5b78/tenor.gif?itemid=9141214"
13 style={{
14 pointerEvents: 'none',
15 width: '100%',
16 height: '100%',
17 objectFit: 'fill'
18 }}
19 />
20</div>

Framer Motion

Now that we have our basic structure, let's add some dragging functionality! To enable Framer Motion props on an element, import motion from framer-motion and change the div to be a motion component by changing the tag to motion.div. As per the docs, any HTML and SVG element can be a motion component.

Now that we have a motion component, we can access the different animation and gesture props available in Framer Motion! Since we want to be able to drag our image anywhere, we can use the drag property. If you want to restrict the dragging so that you can only drag an element across one axis, you can set drag="x" or drag="y". Let's take a look at our component so far:

Pip.jsJS
1import { motion } from "framer-motion";
2
3...
4return (
5 <motion.div
6 drag
7 style={{
8 position: 'absolute',
9 top: 0,
10 left: 0,
11 width: '341.5px', /* maintain aspect ratio of dimensions of this original img source */
12 height: '255px'
13 }}
14 >
15 <img
16 alt="Toddler Korra bending water, earth, and fire"
17 src="https://media1.tenor.com/images/e3ee9db7e7c1a339e2006670c51b5b78/tenor.gif?itemid=9141214"
18 style={{
19 pointerEvents: 'none',
20 width: '100%',
21 height: '100%',
22 objectFit: 'fill'
23 }}
24 />
25 </motion.div>
26)

Woohoo, we have a draggable component! But we still have some problems with it. You can drag it right off the screen! And the only way to get it back, so to speak, is to refresh the page to reset the initial styling position. Let's fix that! We can add a dragConstraints prop to our motion.div to restrict where we can drag the element. That way, we can control where the element can go!

There are a couple of ways to do this in Framer Motion: you can either pass in an object, specifying the furthest distance (in pixels) the element can be dragged towards the top, left, right, and bottom. Or you can also pass in a ref to another component whose dimensions will effectively "constrain" our draggable element. Play around with the dragConstraints to see what else you can do!

JS

Drag the gif!

Toddler Korra bending water, earth, and fire

In my case, I wanted to constrain the drag dimensions according to the size of the parent div in App.js. That way I could always drag the image anywhere within the bounds of the size of App! Let's update our code to pass a ref of our App to our Pip component's dragConstraints.

JS
1// App.js
2import React, { useRef } from 'react';
3import Pip from './components/Pip';
4...
5const App = () => {
6 const appRef = useRef(null);
7
8 return (
9 <div ref={appRef}>
10 <Pip appRef={appRef}/>
11 </div>
12 )
13};
14
15export default App;
16
17// Pip.js
18const Pip = ({ appRef }) => {
19 ...
20 return (
21 <motion.div
22 drag
23 dragConstraints={appRef}
24 style={{
25 position: 'absolute',
26 top: 0,
27 left: 0,
28 width: '341.5px', /* maintain aspect ratio of dimensions of this original img source */
29 height: '255px'
30 }}
31 >
32 <img
33 alt="Toddler Korra bending water, earth, and fire"
34 src="https://media1.tenor.com/images/e3ee9db7e7c1a339e2006670c51b5b78/tenor.gif?itemid=9141214"
35 style={{
36 pointerEvents: 'none',
37 width: '100%',
38 height: '100%',
39 objectFit: 'fill'
40 }}
41 />
42 </motion.div>
43 )

Snap to corner

Alright, our Pip is still draggable, but it can no longer zoom off screen! You'll notice, though, that when you let go from dragging the image, it carries the momentum and eventually comes to a stop somewhere past where you let go. This is due to the default, physics-like behavior of Framer Motion and it's pretty cool! However, it's not exactly the functionality that we want here. It would be pretty annoying if you're trying to drag the image to a different corner and instead, it stopped somewhere in the middle and blocked the text you're trying to read! To accomplish this, we're going to use the dragTransition property. There is a lot of customization you can do with dragTransition, so check out the docs to see what all of the options are!

In the docs, we see that releasing a drag triggers an inertia animation. Amongst the different options for the Framer Motion inertia animation type is a method called modifyTarget. This function receives a "target" number that represents the calculated target, or position, for the element's final destination. Since we want to control what our Pip element's final destination is, we can overwrite the default modifyTarget function with our own!

Pip.jsJS
1import React, { useRef } from 'react';
2
3const Pip = ({ appRef }) => {
4 const pipRef = useRef(null);
5
6 const modifyTarget = (target) => {
7 if (appRef.current && pipRef.current) {
8 const appRect = appRef.current.getBoundingClientRect();
9 const pipRect = pipRef.current.getBoundingClientRect();
10 const pipMiddleX = pipRect.width / 2;
11 const pipMiddleY = pipRect.height / 2;
12
13 if (target + pipMiddleX > appRect.width / 2) {
14 return appRect.width;
15 } else if (target + pipMiddleY > appRect.height / 2) {
16 return appRect.height;
17 }
18
19 return 0;
20 }
21 };
22
23 return (
24 <motion.div
25 ref={pipRef}
26 drag
27 dragConstraints={appRef}
28 dragTransition={{
29 modifyTarget
30 }}
31 >
32 <img
33 alt="Toddler Korra bending water, earth, and fire"
34 src="https://media1.tenor.com/images/e3ee9db7e7c1a339e2006670c51b5b78/tenor.gif?itemid=9141214"
35 style={{
36 pointerEvents: "none",
37 width: "100%",
38 height: "100%",
39 objectFit: "fill"
40 }}
41 />
42 </motion.div>
43 )
44};

Let's break this down a little more. What we want to happen is that if the user releases the drag in the top left corner of our App component, the image "snaps" to the top left corner. If they release it in the top right corner, the image should snap to the top right corner. Likewise with the bottom left and right corners. In order to do this, we need to know what the width and height of our App component is, as well as the position of the middle of our image. Then we can update the Pip's horizontal position to either be 0 (on the left side of App) or the width of App (on the right side of App). Similarly with the vertical position, we can set it to be either 0 (at the top of App) or the height of App (at the bottom of App).

To get these dimensions, we can use the ref for both our App and Pip components and use the getBoundingClientRect() method to get the height and width of both elements. To find the middle of our Pip, we can simply divide both the height and width by 2.

JS
const appRect = appRef.current.getBoundingClientRect();
const pipRect = pipRef.current.getBoundingClientRect();
const pipMiddleX = pipRect.width / 2;
const pipMiddleY = pipRect.height / 2;

The target argument will refer to the position of the top left corner of our element since we've positioned our Pip to have a top: 0 and left: 0. So to figure out if our component was released on the left half of App, we need to compare and see if the middle of Pip was less than or greater than the middle of App (and likewise for determining the vertical position).

JS
if (target + pipMiddleX > appRect.width / 2) {
return appRect.width;
} else if (target + pipMiddleY > appRect.height / 2) {
return appRect.height;
}
return 0;

All together, our modifyTarget function looks like this:

modifyTargetJS
1const modifyTarget = (target) => {
2 if (appRef.current && pipRef.current) {
3 const appRect = appRef.current.getBoundingClientRect();
4 const pipRect = pipRef.current.getBoundingClientRect();
5 const pipMiddleX = pipRect.width / 2;
6 const pipMiddleY = pipRect.height / 2;
7
8 // if Pip is on the right half, update position to the far right side
9 if (target + pipMiddleX > appRect.width / 2) {
10 return appRect.width;
11 }
12
13 // if Pip is on the bottom half, update position to the very bottom
14 else if (target + pipMiddleY > appRect.height / 2) {
15 return appRect.height;
16 }
17
18 // otherwise stay on the left side or top half
19 return 0;
20 }
21};

It's not perfect logic since there's no way to know if the provided target is the new x or y coordinate (it's just a plain ol' number), but this combined with our dragConstraints will help give general "boundaries" to determine which corner our Pip should end up in when we release the drag.

Customizing the animation

Now that we have the expected functionality, let's customize the animation a bit more! To do this, we'll revisit that dragTransition property, as well as dragElastic.

The value of dragElastic will be a number between 0 and 1 (0.5 by default) and determines how "elastic" our drag constraint boundaries are. In other words, how much is our element allowed to move outside of our boundaries as it's animating to its final position? In my case, I chose a value of 0.1 to make it a bit more rigid.

Now, let's say we want it to be a bit "snappier" when we let go of the Pip. Taking a look at the inertia animation docs, there are quite a few properties we can play with! Feel free to explore and experiment to get the effect you want. The ones I used are power, min, max, and timeConstant (in addition to modifyTarget from earlier). To be perfectly honest, the docs for the inertia animation aren't the clearest and it really just took a lot of trial and error to get something I liked. But one I did understand and came to really like is timeConstant. This prop changes the duration of the element's deceleration as it's animating. So changing this value will change the feel of the whole animation by seemingly speeding up or slowing down the "snap" to its final position. timeConstant is set to 700 by default, so it might take a bit of tinkering, as well.

Here's our updated component:

Pip.jsJS
1...
2<motion.div
3 ref={pipRef}
4 drag
5 dragConstraints={appRef}
6 dragElastic={0.1}
7 dragTransition={{
8 modifyTarget,
9 power: 0,
10 min: 0,
11 max: 200,
12 timeConstant: 250
13 }}
14>
15 <img
16 alt="Toddler Korra bending water, earth, and fire"
17 src="https://media1.tenor.com/images/e3ee9db7e7c1a339e2006670c51b5b78/tenor.gif?itemid=9141214"
18 style={{
19 pointerEvents: "none",
20 width: "100%",
21 height: "100%",
22 objectFit: "fill"
23 }}
24 />
25</motion.div>

Final result

Putting it all together with our Pip inside of App, and here's the result! Feel free to edit the code to see live changes.

PipJS

Drag the gif!

Toddler Korra bending water, earth, and fire

Drawbacks

I've been tinkering with a version of this component using an iframe instead of an img tag so that I can have a draggable video pip. However, because I had to set pointer-events: none on the child element of the Pip component, you can't interact with the video. So for the purposes of this post, I just used an img tag so that people wouldn't be trying to click on the video to play it 😅 I've experimented with having a div overlay that has the video controls (using YouTube's iframe API), but have so far been unsuccessful. If anyone figures out a solution, I'd love to hear it! 😄