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!
Introducing usePip - a React Hook for creating Picture-in-Picture windows.
— R. Alex Anderson 🚀 (@ralex1993) December 3, 2020
It uses React Spring and React Use Gesture and mimics the behavior of Safari's PIP windows on macOS by snapping to corners (unless the command key is held down)
Copypasta it here! https://t.co/yJeU88xbZg pic.twitter.com/w8Yr5bJYub
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!
Drag the gif!
Structure
The setup is pretty barebones! There's a containing App
component and then the Pip
component as a child of App
.
1import React from "react";2import Pip from "./components/Pip";34export default function App() {5 const appRef = React.useRef(null);67 return (8 <div9 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.
1<div2 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 <img11 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:
1import { motion } from "framer-motion";23...4return (5 <motion.div6 drag7 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 <img16 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!
Drag the gif!
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
.
1// App.js2import React, { useRef } from 'react';3import Pip from './components/Pip';4...5const App = () => {6 const appRef = useRef(null);78 return (9 <div ref={appRef}>10 <Pip appRef={appRef}/>11 </div>12 )13};1415export default App;1617// Pip.js18const Pip = ({ appRef }) => {19 ...20 return (21 <motion.div22 drag23 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 <img33 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!
1import React, { useRef } from 'react';23const Pip = ({ appRef }) => {4 const pipRef = useRef(null);56 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;1213 if (target + pipMiddleX > appRect.width / 2) {14 return appRect.width;15 } else if (target + pipMiddleY > appRect.height / 2) {16 return appRect.height;17 }1819 return 0;20 }21 };2223 return (24 <motion.div25 ref={pipRef}26 drag27 dragConstraints={appRef}28 dragTransition={{29 modifyTarget30 }}31 >32 <img33 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.
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).
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:
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;78 // if Pip is on the right half, update position to the far right side9 if (target + pipMiddleX > appRect.width / 2) {10 return appRect.width;11 }1213 // if Pip is on the bottom half, update position to the very bottom14 else if (target + pipMiddleY > appRect.height / 2) {15 return appRect.height;16 }1718 // otherwise stay on the left side or top half19 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:
1...2<motion.div3 ref={pipRef}4 drag5 dragConstraints={appRef}6 dragElastic={0.1}7 dragTransition={{8 modifyTarget,9 power: 0,10 min: 0,11 max: 200,12 timeConstant: 25013 }}14>15 <img16 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.
Drag the gif!
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! 😄