Updated on 3rd of August: slightly modified overshoot behavior to support dragElastic prop.
Framer Motion allows you to make an element draggable with a single prop. With a couple more props, you can restrict where the element can be dragged, lock its direction, define if it should have momentum, or attach event listeners.
However, a lot of components that need dragging often require snapping too. So after you release the element, it snaps to one of the pre-defined positions, rather than just hanging where you left it. For example, when you drag and then release a file in Finder/Explorer, it will stick to the grid (if you don't have it disabled, of course). Or when swiping photos in the gallery, you can't get stuck in-between photos; one of them will snap to viewport.
Unfortunately, Framer Motion doesn't have a built-in way to add snap points, so we'll have to code them ourselves! Today we won't be making a particular component, we'll aim for a universal solution that will cover most of the cases, so you will be able to use it in your projects right away. Snapping turned out to be a lot more complex than I initially thought, and I spent a good chunk of time making this hook. But hopefully this article will help you get going in no time!
So we need a reusable piece of code that can be shared between components. That sounds like a React hook to me! This hook will receive our pre-defined snap points along with other parameters it might need. Since layout will be different between different components, it will be handled in the component itself rather than in the hook. But the hook still needs a way to listen for the element's drag events and pass drag props to it. This can be done by returning dragProps from the hook, which the developer will need to pass to the element. Overall, it will look like this:
Now let's talk about hook parameters. First and foremost, snap points. From the implementation standpoint, it will be easiest if all snap points are in absolute coordinates related to the top left corner of the page (or of the viewport, if the element has positon: fixed).
But it's not very convenient for the developer, as most of the components have snap points either relative to their initial position or the size and position of their container. And so the developer will need to get the element's position on the page, convert snap point coordinates into absolute ones, and only then pass them to the hook. Not a good developer experience :/. Instead, let's support different types of snap points and convert them inside the hook. A snap point can be described as either a single x or y coordinate, or both.
Other parameters will include drag direction, element's ref, constraints, and others. We'll cover each of them in more detail as we go.
Since the hook itself isn't awfully interactive, I'll use this simple component to showcase how our hook works on different stages. Red dots and lines are our future snap points. If you wish to code along, here is its source code.
Component:
Styles (SCSS):
We'll be working on a hook today, but for proper work, it requires an additional couple of types and helper functions. They are all pretty straightforward, so we won't stop at them too much. Just copy them in your editor if you're coding along, and let's go.
To snap our element, we, first of all, need to know all snap points. As I mentioned before, absolute coordinates will be easiest to work with, so we'll write a function to convert snap points. However, to convert snap points based on constraints box/element to absolute snap points, we need to measure constraints first.
Constraints can be set in two formats: element's ref (to constraint element to parent's boundaries) or object, which describes how far the user can drag the element in each direction. Framer Motion already has a handy callback onMeasureDragConstraints which will be called if dragConstraints is an element ref. Let's use it to our advantage and save measured constraints into a ref.
We put callback into dragProps which we return from the hook, and which developer will need to pass directly to the element.
Framer Motion will execute callback with measured constraints that look like this:
Now we need to convert this object into absolute coordinates.
To calculate absolute coordinates, we need to know the element's position on the page. And what's important, we need element's position before any translate was applied to it. Unfortunately, getBoundingClientRect will return the element's current position, including any transforms applied to it. To get base coordinates, we need to know how much shift translate added. We could try to parse the transform string ourselves, but fortunately there is a built-in API for this! You can create an instance of DOMMatrixReadOnly by passing a transform string, and the browser will calculate the exact amount in pixels. Names are a bit weird (transformMatrix.e for translateX, transformMatrix.f for translateY), but it works!
And since getBoundingClientRect returns coordinates relative to the viewport (and we'd like to have page coordinates everywhere), we need to add window.scrollX/Y to our calculations. The rest is just simple math, converting from relative coordinates to absolute. Element also can be partially constrained (e.g. only from left and top), so our function accounts for that too.
Now, with measured and converted constraints, we can finally convert our snap points.
The code is relatively simple (albeit a bit lengthy), so I won't comment much on it.
Now, that we have our snap points, we can try to implement basic snapping.
Snapping happens only when the user releases the element; we shouldn't mess with the element while the user is still dragging. onDragEnd callback is called when the user finishes the drag gesture, and we'll calculate snap points in it. We'll add our onDragEndHandler to dragProps. And while we're at it, let's update dragProps to include all required props.
The idea behind snapping is simple: we need to find a point that is closest to our element, and then animate translateX/Y so the element will move to this point. Let's implement this.
And here is our demo component wired with the current version of the useSnap hook. Give it a try.
While the current solution does work, it still has some room for improvement. For example, it feels a bit choppy when you're releasing an element while still moving it. It stops right there and then gets snapped to the nearest point. Another area of improvement is an API; currently, it's really minimal and won't be sufficient for many components that require snapping.
In the real world, when you move something and then let it go, it will continue moving for some time. It depends on how quickly you are moving the object, its mass, and friction with the surface. By default, Framer Motion replicates the same behavior for drag elements.
However, our hook doesn't do this. In our case, the element stops right where the user released it and then starts moving to the snap point. And because of this, it feels kinda abrupt and choppy. To fix this, we'll need to calculate the element's position "after inertia" and search for the nearest snap point from this position, not the current element's position (i.e., where the user released it).
To calculate this position, we need to simply multiply power by velocity and add the result to the drop coordinates. This doesn't sound like a formula you might have learned in school when studying momentum, but it's used in Framer Motion itself, and I think it's better to match Framer Motion's behavior.
We'll use a hard-coded value for power, but if you want, you can make it one of the hook's options. It defines how "slippery" your element is: higher values will make the element slide farther, while lower values will give it less momentum.
And for velocity, Framer Motion provides velocity for each direction in the info object, which is the second argument in the onDragEnd handler.
With these changes, we take momentum into account when selecting a point to snap, but we don't actually show it to the user; our element still stops right after the user releases it and then starts animating to the snap point. To fix this, we'll again take advantage of velocity.
Spring transition can accept initial velocity and take it into account when animating a value. What we need to do is transfer velocity from mouse movement to spring transition. This will make the transition from dragging to snapping animation unnoticeable. For this, we need to update our calls to the animate function.
However, this will only handle cases when the snap point has both coordinates defined. If there is only x or y, only one axis will be animated, and the other one will behave the same as previously (abruptly stopping). So we need to handle this case too and animate the element's position even if the snap point doesn't use this axis.
And here's how it works now. Try dragging the element from top left corner to bottom right and release it in the middle. If it has enough velocity, it will snap to further snap point, even though it wasn't the closest one when you released the element.
Better, right?
However, there is one more issue. If you throw the element really hard, it will have such a big velocity that it will move far out of its constraints box before snapping to the selected point. To fix this, we need to reduce velocity if it's too big. And to know if it's too big, we need to calculate how far past its constraints box element will go. Then, depending on this distance, we'll calculate the coefficient, which will later be multiplied by velocity.
And then, we need to use the new value for velocity when animating:
What just happened? What is this Math.pow and Math.abs abomination? Let's break this down. We start with overshootCoefficient of 1, which won't modify the velocity. Then, for each side, we calculate how far the element will move past this constraint's box side:
Then we raise overshootDecreaseCoefficient to the power of distance and multiply by dragElastic value for respective side. The resulting number is then used as a overshootCoefficient for the axis. Raising a number less than 1 to a power makes it smaller. The bigger the power, the quicker the resulting number decreases. Which is just what we need. The bigger the overshoot, the smaller the overshootCoefficient will be, and thus velocity will be reduced more significantly.
You can adjust overshootDecreaseCoefficient to your liking or even make it one of the hook's options. A smaller value will decrease velocity more significantly. 0.999 is just what looked nice to me!
The current API really limits where you can use this hook. For example, you can't keep track of the point that the element is currently snapped to. And you can't programmatically snap to a selected point. This is the bare minimum we need. There are other possible ways to make this hook more universal, but implementing them all will make this post a lot longer, and it's long enough already!
Let's start with keeping track of the current snap point, that's easy!
Now we need to reset the index when the user starts dragging the element:
And update it once we select to which point the element should snap in onDragEndHandler:
To programmatically snap to a selected point, we'll expose snapTo function:
snapTo is essentially a simplified version of onDragEndHandler, since we don't need to take inertia into account or calculate which snap point is the closest. We just calculate the required values for translateX/Y and animate the element using the same approach as in onDragEndHandler.
That's all; you made it to the end. Congratulations! Now go build something cool with your new hook.
Published at 24 June 2024
Last updated at 3 August 2024
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.