How to make draggable Framer Motion elements snap to breakpoints

How to make draggable Framer Motion elements snap to breakpoints

Published at 24 June 2024

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!

Table of contents

What we are building

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:

const SnapPoints = () => {
    const { dragProps } = useSnap({ /* Hook parameters */ });
 
    return (<div className="container">
        <motion.button
            className='handle'
            {...dragProps}
        />
    </div>);
};

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.

Let's get to coding

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:

export const VanillaDrag = () => {
    const containerRef = useRef<HTMLDivElement>(null);
 
    const [width, setWidth] = useState(0);
    const [height, setHeight] = useState(0);
 
    useLayoutEffect(() => {
        setWidth(containerRef.current?.getBoundingClientRect().width ?? 0);
        setHeight(containerRef.current?.getBoundingClientRect().height ?? 0);
    }, []);
 
    const handleWidth = 100;
    const handleHeight = 40;
 
    // This later will be used in the hook
    const snapPoints = {
        type: 'constraints-box',
        unit: 'percent',
        points: [
            { x: 0.1, y: 0.1 },
            { x: 0.3, y: 0.2 },
            { y: 0.5 },
            { x: 0.75 },
            { x: 0.9, y: 0.9 },
            { x: 1, y: 1 }
        ]
    };
 
    return (<motion.div className="SnappingExample" ref={containerRef}>
        {snapPoints.points.map((p, ind) => (<div
            key={ind} // Array is static so it's fine to use index as key
            className="snappoint"
            style={{
                top: p.y === undefined ? '0' : (height - handleHeight) * p.y,
                bottom: p.y === undefined ? '0' : undefined,
                left: p.x === undefined ? '0' : (width - handleWidth) * p.x,
                right: p.x === undefined ? '0' : undefined,
                width: p.x === undefined ? undefined : p.y === undefined ? 4 : 8,
                height: p.y === undefined ? undefined : p.x === undefined ? 4 : 8,
            }}
        />))}
 
        <motion.button
            className="drag-handle"
            style={{ width: handleWidth, height: handleHeight }}
            drag
            dragConstraints={containerRef}
        >
            Drag me!
        </motion.button>
    </motion.div>);
};

Styles (SCSS):

.SnappingExample {
    width: 100%;
    min-height: 500px;
    border-radius: 4px;
    border: 2px solid #0479cd;
    position: relative;
 
    .drag-handle {
        background: #0aa65d;
        padding: 8px;
        border: none;
        border-radius: 4px;
        position: relative;
        cursor: grab;
        color: white;
        font-family: inherit;
        font-size: 16px;
    }
 
    .snappoint {
        background: #cd0404;
        border-radius: 4px;
        position: absolute;
        opacity: 0.5;
    }
}

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.

import { BoundingBox, MotionProps, DragHandlers, animate, SpringOptions } from "framer-motion";
import { RefObject, useRef, useState } from "react";
 
export type Point = {
    x?: number,
    y?: number,
};
 
export type SnapPointsType =
    | { type: 'absolute', points: Point[] }
    | {
        // Based on constraints box
        type: 'constraints-box',
        unit: 'pixel' | 'percent',
        points: Point[]
    }
    | {
        // Relative to initial position
        type: 'relative-to-initial',
        points: Point[]
    };
 
export type SnapOptions = {
    direction: "x" | "y" | "both",
    ref: RefObject<Element>,
    snapPoints: SnapPointsType,
    springOptions?: Omit<SpringOptions, 'velocity'>,
    constraints?: Partial<BoundingBox> | RefObject<Element>,
    onDragStart?: MotionProps["onDragStart"],
    onDragEnd?: MotionProps["onDragEnd"],
    onMeasureDragConstraints?: MotionProps["onMeasureDragConstraints"],
};
 
export type UseSnapResult = {
    dragProps: Pick<MotionProps,
        | 'drag'
        | 'onDragStart'
        | 'onDragEnd'
        | 'onMeasureDragConstraints'
        | 'dragMomentum'
    > & Partial<Pick<MotionProps, 'dragConstraints'>>;
    snapTo: (index: number) => void;
    currentSnappointIndex: number | null;
};
 
const minmax = (num: number, min: number, max: number) => Math.max(Math.min(max, num), min);
 
export const useSnap = ({
    direction, snapPoints, ref, springOptions = {}, constraints,
    onDragStart, onDragEnd, onMeasureDragConstraints,
}: SnapOptions): UseSnapResult => {
    // Actual code will be here
};

All further code will go inside the useSnap hook.

Converting snap points

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.

// This all goes in `useSnap` hook!
 
const constraintsBoxRef = useRef<BoundingBox | null>(null);
 
const dragProps: Partial<MotionProps> = {
    onMeasureDragConstraints(constraints) {
        constraintsBoxRef.current = constraints;
        onMeasureDragConstraints?.(constraints);
    },
};
 
return {
    dragProps,
};

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:

{
    left: 100,
    right: 200,
    top: 100,
    bottom: 200,
}

Now we need to convert this object into absolute coordinates.

const resolveConstraints = () => {
    if (constraints === undefined) {
        return null;
    };
 
    if (!ref.current) {
        throw new Error('Element ref is empty')
    };
 
    const box = 'current' in constraints ? constraintsBoxRef.current : constraints;
    if (!box) {
        throw new Error("Constraints wasn't measured");
    }
 
 
    // Thanks Claude Opus and this question 
    // on SO https://stackoverflow.com/questions/53866942/css-transform-matrix-to-pixel
    const elementBox = ref.current.getBoundingClientRect();
    const style = window.getComputedStyle(ref.current);
    const transformMatrix = new DOMMatrixReadOnly(style.transform);
    const baseX = window.scrollX + elementBox.x - transformMatrix.e;
    const baseY = window.scrollY + elementBox.y - transformMatrix.f;
 
    const left = box.left !== undefined ? baseX + box.left : undefined;
    const top = box.top !== undefined ? baseY + box.top : undefined;
 
    const right = box.right !== undefined ? baseX + box.right : undefined;
    const bottom = box.bottom !== undefined ? baseY + box.bottom : undefined;
 
    const width = (left !== undefined && right !== undefined) ? right - left : undefined;
    const height = (top !== undefined && bottom !== undefined) ? bottom - top : undefined;
 
    return {
        left,
        top,
        width,
        height,
        right,
        bottom,
    };
};

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.

const convertSnappoints = (snapPoints: SnapPointsType) => {
    if (!ref.current) return null;
 
    if (snapPoints.type === 'absolute') {
        return snapPoints.points;
    }
 
    if (snapPoints.type === 'relative-to-initial') {
        // Same trick as before
        const elementBox = ref.current.getBoundingClientRect();
        const style = window.getComputedStyle(ref.current);
        const transformMatrix = new DOMMatrixReadOnly(style.transform);
        const translateX = transformMatrix.e;
        const translateY = transformMatrix.f;
        const baseX = window.scrollX + elementBox.x - translateX;
        const baseY = window.scrollY + elementBox.y - translateY;
 
        return snapPoints.points.map(p => {
            return {
                x: p.x === undefined ? undefined : baseX + p.x,
                y: p.y === undefined ? undefined : baseY + p.y,
            }
        });
    } else if (snapPoints.type === 'constraints-box') {
        if (constraints === undefined) {
            throw new Error(`When using snapPoints type constraints-box, you must provide 'constraints' property`);
        }
 
        const box = resolveConstraints();
        if (!box) {
            throw new Error(`constraints weren't provided`);
        }
 
        if (['x', 'both'].includes(direction) && (box.left === undefined || box.right === undefined)) {
            throw new Error(`constraints should describe both sides for each used drag direction`);
        }
        if (['y', 'both'].includes(direction) && (box.top === undefined || box.bottom === undefined)) {
            throw new Error(`constraints should describe both sides for each used drag direction`);
        }
 
        return snapPoints.points.map(p => {
            const result: Point = {};
            if (p.x !== undefined) {
                if (snapPoints.unit === 'pixel') {
                    result.x = box.left! + p.x;
                } else {
                    result.x = box.left! + (box.width! * p.x);
                }
            }
            if (p.y !== undefined) {
                if (snapPoints.unit === 'pixel') {
                    result.y = box.top! + p.y;
                } else {
                    result.y = box.top! + (box.height! * p.y);
                }
            }
            return result;
        });
    } else {
        throw new Error('Unknown snapPoints type');
    }
};

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.

Snap, snap, snap

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.

const onDragEndHandler: DragHandlers["onDragEnd"] = (event, info) => {
    onDragEnd?.(event, info);
 
    if (!ref.current) {
        throw new Error('element ref is not set');
    }
 
    const points = convertSnappoints(snapPoints);
    console.log('Converted snappoints', points);
    if (!points) {
        throw new Error(`snap points weren't calculated on drag start`);
    }
 
    const elementBox = ref.current.getBoundingClientRect();
    const style = window.getComputedStyle(ref.current);
    const transformMatrix = new DOMMatrixReadOnly(style.transform);
    const translate = { x: transformMatrix.e, y: transformMatrix.f };
    const base = {
        x: window.scrollX + elementBox.x - translate.x,
        y: window.scrollY + elementBox.y - translate.y,
    };
 
    // Snapping code will be here
}
 
const dragProps: Partial<MotionProps> = {
    drag: direction === 'both' ? true : direction,
    onDragEnd: onDragEndHandler,
    onMeasureDragConstraints(constraints) {
        constraintsBoxRef.current = constraints;
        onMeasureDragConstraints?.(constraints);
    },
 
    dragMomentum: false, // We'll handle this ourselves
    dragConstraints: constraints,
};

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.

// This code goes still in `onDragEndHandler` function
 
// Current coordinates of an element
const dropCoordinates = {
    x: window.scrollX + elementBox.x,
    y: window.scrollY + elementBox.y,
};
 
const distances = points.map((p) => {
    if (p.x !== undefined && p.y !== undefined) {
        // https://en.wikipedia.org/wiki/Euclidean_distance
        return Math.sqrt(Math.pow(p.x - dropCoordinates.x, 2) + Math.pow(p.y - dropCoordinates.y, 2));
    }
    if (p.x !== undefined) return Math.abs(p.x - dropCoordinates.x);
    if (p.y !== undefined) return Math.abs(p.y - dropCoordinates.y);
    return 0;
});
const minDistance = Math.min(...distances);
const minDistanceIndex = distances.indexOf(minDistance);
const selectedPoint = points[minDistanceIndex];
 
// Values for `translateX/Y` to move element to selected point
const target = {
    x: selectedPoint.x !== undefined
        ? selectedPoint.x - base.x
        : undefined,
    y: selectedPoint.y !== undefined
        ? selectedPoint.y - base.y
        : undefined,
};
 
if (selectedPoint.x !== undefined) {
    animate(
        ref.current,
        { x: target.x },
        {
            ...springOptions,
            type: 'spring',
        }
    );
}
if (selectedPoint.y !== undefined) {
    animate(
        ref.current,
        { y: target.y },
        {
            ...springOptions,
            type: 'spring',
        }
    );
}

And here is our demo component wired with the current version of the useSnap hook. Give it a try.

From PoC to reusable hook

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.

Inertia and velocity transfer

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.

How inertia works

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.

// in onDragEndHandler function
const power = 0.15;
 
const afterInertia = {
    x: dropCoordinates.x + (power * info.velocity.x),
    y: dropCoordinates.y + (power * info.velocity.y),
};
 
const distances = points.map((p) => {
    // Now we need to use coordinates after inertia to find nearest snap point
    if (p.x !== undefined && p.y !== undefined) {
        return Math.sqrt(Math.pow(p.x - afterInertia.x, 2) + Math.pow(p.y - afterInertia.y, 2));
    }
    if (p.x !== undefined) return Math.abs(p.x - afterInertia.x);
    if (p.y !== undefined) return Math.abs(p.y - afterInertia.y);
    return 0;
});

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.

animate(
    ref.current,
    { x: target.x },
    {
        ...springOptions,
        type: 'spring',
        velocity: info.velocity.x,
    }
);
// And same for y axis

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.

// Clamp value so we don't move out of constraints box
const constraintsBox = resolveConstraints();
const afterInertiaClamped = {
    x: minmax(
        afterInertia.x,
        constraintsBox?.left ?? -Infinity,
        constraintsBox?.right ?? Infinity,
    ),
    y: minmax(
        afterInertia.y,
        constraintsBox?.top ?? -Infinity,
        constraintsBox?.bottom ?? Infinity,
    ),
};
 
const target = {
    x: selectedPoint.x !== undefined
        ? selectedPoint.x - base.x
        : afterInertiaClamped.x - base.x,
    y: selectedPoint.y !== undefined
        ? selectedPoint.y - base.y
        : afterInertiaClamped.y - base.y,
};
 
// We should animate element coordinate only if drag is enabled for this axis
if (direction === 'x' || direction === 'both') {
    animate(
        ref.current,
        { x: target.x },
        {
            ...springOptions,
            type: 'spring',
            velocity: info.velocity.x,
        }
    );
}
 
if (direction === 'y' || direction === 'both') {
    animate(
        ref.current,
        { y: target.y },
        {
            ...springOptions,
            type: 'spring',
            velocity: info.velocity.y,
        }
    );
}

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.

// After afterInertiaClamped calculations, but before `target`
 
const overshootCoefficient = { x: 1, y: 1 };
const overshootDecreaseCoefficient = 0.999;
 
if (constraintsBox?.left !== undefined && afterInertia.x < constraintsBox.left) {
    overshootCoefficient.x = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.left - afterInertia.x));
}
if (constraintsBox?.right !== undefined && afterInertia.x > constraintsBox.right) {
    overshootCoefficient.x = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.right - afterInertia.x));
}
if (constraintsBox?.top !== undefined && afterInertia.y < constraintsBox.top) {
    overshootCoefficient.y = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.top - afterInertia.y));
}
if (constraintsBox?.bottom !== undefined && afterInertia.y > constraintsBox.bottom) {
    overshootCoefficient.y = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.bottom - afterInertia.y));
}
 
const velocity = {
    x: info.velocity.x * overshootCoefficient.x,
    y: info.velocity.y * overshootCoefficient.y,
};

And then, we need to use the new value for velocity when animating:

animate(
    ref.current,
    { x: target.x },
    {
        ...springOptions,
        type: 'spring',
        velocity: velocity.x,
        //         ^^^^^^
    }
);

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:

const distance = Math.abs(constraintsBox.left - afterInertia.x)

Then we raise overshootDecreaseCoefficient to the power of distance and use the resulting number 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.

Plot

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!

API improvements

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!

// We'll use state for this
const [currentSnappointIndex, setCurrentSnappointIndex] = useState<null | number>(null);
 
// <...>
 
// Return currentSnappointIndex from hook
return {
    dragProps,
    currentSnappointIndex
};

Now we need to reset the index when the user starts dragging the element:

const dragProps: Partial<MotionProps> = {
    drag: direction === 'both' ? true : direction,
    // We reset currentSnappointIndex when user starts dragging element
    onDragStart: (event, info) => {
        setCurrentSnappointIndex(null);
        onDragStart?.(event, info);
    },
    onDragEnd: onDragEndHandler,
    // <...>
};

And update it once we select to which point the element should snap in onDragEndHandler:

// <...>
const minDistance = Math.min(...distances);
const minDistanceIndex = distances.indexOf(minDistance);
setCurrentSnappointIndex(minDistanceIndex);
const selectedPoint = points[minDistanceIndex];
// <...>

To programmatically snap to a selected point, we'll expose snapTo function:

const snapTo = (index: number) => {
    const converted = convertSnappoints(snapPoints);
    if (!converted || !ref.current) return;
    const convertedPoint = converted[index];
    if (!convertedPoint) return;
 
    const elementBox = ref.current.getBoundingClientRect();
    const style = window.getComputedStyle(ref.current);
    const transformMatrix = new DOMMatrixReadOnly(style.transform);
    const translate = { x: transformMatrix.e, y: transformMatrix.f };
    const base = {
        x: window.scrollX + elementBox.x - translate.x,
        y: window.scrollY + elementBox.y - translate.y,
    };
 
    setCurrentSnappointIndex(index);
    if (convertedPoint.x !== undefined) {
        animate(
            ref.current,
            { x: convertedPoint.x - base.x },
            {
                ...springOptions,
                type: 'spring',
            }
        );
    }
    if (convertedPoint.y !== undefined) {
        animate(
            ref.current,
            { y: convertedPoint.y - base.y },
            {
                ...springOptions,
                type: 'spring',
            }
        );
    }
};
 
// <...>
 
// Add snapTo to returned object
return {
    dragProps,
    snapTo,
    currentSnappointIndex
};

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.

import { BoundingBox, MotionProps, DragHandlers, animate, SpringOptions } from "framer-motion";
import { RefObject, useRef, useState } from "react";
 
export type Point = {
    x?: number,
    y?: number,
};
 
export type SnapPointsType =
    | { type: 'absolute', points: Point[] }
    | {
        // Based on constraints box
        type: 'constraints-box',
        unit: 'pixel' | 'percent',
        points: Point[]
    }
    | {
        // Relative to initial position
        type: 'relative-to-initial',
        points: Point[]
    };
 
export type SnapOptions = {
    direction: "x" | "y" | "both",
    ref: RefObject<Element>,
    snapPoints: SnapPointsType,
    springOptions?: Omit<SpringOptions, 'velocity'>,
    constraints?: Partial<BoundingBox> | RefObject<Element>,
    onDragStart?: MotionProps["onDragStart"],
    onDragEnd?: MotionProps["onDragEnd"],
    onMeasureDragConstraints?: MotionProps["onMeasureDragConstraints"],
};
 
export type UseSnapResult = {
    dragProps: Pick<MotionProps,
        | 'drag'
        | 'onDragStart'
        | 'onDragEnd'
        | 'onMeasureDragConstraints'
        | 'dragMomentum'
    > & Partial<Pick<MotionProps, 'dragConstraints'>>;
    snapTo: (index: number) => void;
    currentSnappointIndex: number | null;
};
 
const minmax = (num: number, min: number, max: number) => Math.max(Math.min(max, num), min);
 
export const useSnap = ({
    direction, snapPoints, ref, springOptions = {}, constraints,
    onDragStart, onDragEnd, onMeasureDragConstraints,
}: SnapOptions): UseSnapResult => {
    const resolveConstraints = () => {
        if (constraints === undefined) {
            return null;
        };
 
        if (!ref.current) {
            throw new Error('Element ref is empty')
        };
 
        const box = 'current' in constraints ? constraintsBoxRef.current : constraints;
        if (!box) {
            throw new Error("Constraints wasn't measured");
        }
 
 
        const elementBox = ref.current.getBoundingClientRect();
        const style = window.getComputedStyle(ref.current);
        const transformMatrix = new DOMMatrixReadOnly(style.transform);
        const baseX = window.scrollX + elementBox.x - transformMatrix.e;
        const baseY = window.scrollY + elementBox.y - transformMatrix.f;
 
        const left = box.left !== undefined ? baseX + box.left : undefined;
        const top = box.top !== undefined ? baseY + box.top : undefined;
 
        const right = box.right !== undefined ? baseX + box.right : undefined;
        const bottom = box.bottom !== undefined ? baseY + box.bottom : undefined;
 
        const width = (left !== undefined && right !== undefined) ? right - left : undefined;
        const height = (top !== undefined && bottom !== undefined) ? bottom - top : undefined;
 
        return {
            left,
            top,
            width,
            height,
            right,
            bottom,
        };
    };
 
    const convertSnappoints = (snapPoints: SnapPointsType) => {
        if (!ref.current) return null;
 
        if (snapPoints.type === 'absolute') {
            return snapPoints.points;
        }
 
        if (snapPoints.type === 'relative-to-initial') {
            const elementBox = ref.current.getBoundingClientRect();
            const style = window.getComputedStyle(ref.current);
            const transformMatrix = new DOMMatrixReadOnly(style.transform);
            const translateX = transformMatrix.e;
            const translateY = transformMatrix.f;
            const baseX = window.scrollX + elementBox.x - translateX;
            const baseY = window.scrollY + elementBox.y - translateY;
 
            return snapPoints.points.map(p => {
                return {
                    x: p.x === undefined ? undefined : baseX + p.x,
                    y: p.y === undefined ? undefined : baseY + p.y,
                }
            });
        } else if (snapPoints.type === 'constraints-box') {
            if (constraints === undefined) {
                throw new Error(`When using snapPoints type constraints-box, you must provide 'constraints' property`);
            }
 
            const box = resolveConstraints();
            if (!box) {
                throw new Error(`constraints weren't provided`);
            }
 
            if (['x', 'both'].includes(direction) && (box.left === undefined || box.right === undefined)) {
                throw new Error(`constraints should describe both sides for each used drag direction`);
            }
            if (['y', 'both'].includes(direction) && (box.top === undefined || box.bottom === undefined)) {
                throw new Error(`constraints should describe both sides for each used drag direction`);
            }
 
            return snapPoints.points.map(p => {
                const result: Point = {};
                if (p.x !== undefined) {
                    if (snapPoints.unit === 'pixel') {
                        result.x = box.left! + p.x;
                    } else {
                        result.x = box.left! + (box.width! * p.x);
                    }
                }
                if (p.y !== undefined) {
                    if (snapPoints.unit === 'pixel') {
                        result.y = box.top! + p.y;
                    } else {
                        result.y = box.top! + (box.height! * p.y);
                    }
                }
                return result;
            });
        } else {
            throw new Error('Unknown snapPoints type');
        }
    };
 
    const onDragEndHandler: DragHandlers["onDragEnd"] = (event, info) => {
        onDragEnd?.(event, info);
 
        if (!ref.current) {
            throw new Error('element ref is not set');
        }
 
        const points = convertSnappoints(snapPoints);
        console.log('Converted snappoints', points);
        if (!points) {
            throw new Error(`snap point weren't calculated on drag start`);
        }
 
        const constraintsBox = resolveConstraints();
        const elementBox = ref.current.getBoundingClientRect();
        const style = window.getComputedStyle(ref.current);
        const transformMatrix = new DOMMatrixReadOnly(style.transform);
        const translate = { x: transformMatrix.e, y: transformMatrix.f };
        const base = {
            x: window.scrollX + elementBox.x - translate.x,
            y: window.scrollY + elementBox.y - translate.y,
        };
 
        const dropCoordinates = {
            x: window.scrollX + elementBox.x,
            y: window.scrollY + elementBox.y,
        };
 
        const power = 0.15;
 
        const afterInertia = {
            x: dropCoordinates.x + (power * info.velocity.x),
            y: dropCoordinates.y + (power * info.velocity.y),
        };
 
        const distances = points.map((p) => {
            if (p.x !== undefined && p.y !== undefined) {
                return Math.sqrt(Math.pow(p.x - afterInertia.x, 2) + Math.pow(p.y - afterInertia.y, 2));
            }
            if (p.x !== undefined) return Math.abs(p.x - afterInertia.x);
            if (p.y !== undefined) return Math.abs(p.y - afterInertia.y);
            return 0;
        });
        const minDistance = Math.min(...distances);
        const minDistanceIndex = distances.indexOf(minDistance);
        setCurrentSnappointIndex(minDistanceIndex);
        const selectedPoint = points[minDistanceIndex];
 
        const afterInertiaClamped = {
            x: minmax(
                afterInertia.x,
                constraintsBox?.left ?? -Infinity,
                constraintsBox?.right ?? Infinity,
            ),
            y: minmax(
                afterInertia.y,
                constraintsBox?.top ?? -Infinity,
                constraintsBox?.bottom ?? Infinity,
            ),
        };
 
        const overshootCoefficient = { x: 1, y: 1 };
        const overshootDecreaseCoefficient = 0.999;
 
        if (constraintsBox?.left !== undefined && afterInertia.x < constraintsBox.left) {
            overshootCoefficient.x = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.left - afterInertia.x));
        }
        if (constraintsBox?.right !== undefined && afterInertia.x > constraintsBox.right) {
            overshootCoefficient.x = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.right - afterInertia.x));
        }
        if (constraintsBox?.top !== undefined && afterInertia.y < constraintsBox.top) {
            overshootCoefficient.y = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.top - afterInertia.y));
        }
        if (constraintsBox?.bottom !== undefined && afterInertia.y > constraintsBox.bottom) {
            overshootCoefficient.y = Math.pow(overshootDecreaseCoefficient, Math.abs(constraintsBox.bottom - afterInertia.y));
        }
 
        const velocity = {
            x: info.velocity.x * overshootCoefficient.x,
            y: info.velocity.y * overshootCoefficient.y,
        };
 
        const target = {
            x: selectedPoint.x !== undefined
                ? selectedPoint.x - base.x
                : afterInertiaClamped.x - base.x,
            y: selectedPoint.y !== undefined
                ? selectedPoint.y - base.y
                : afterInertiaClamped.y - base.y,
        };
 
        console.log('Snapping result', { target, velocity, afterInertia, afterInertiaClamped, selectedPoint });
 
        if (direction === 'x' || direction === 'both') {
            animate(
                ref.current,
                { x: target.x },
                {
                    ...springOptions,
                    type: 'spring',
                    velocity: velocity.x,
                }
            );
        }
        if (direction === 'y' || direction === 'both') {
            animate(
                ref.current,
                { y: target.y },
                {
                    ...springOptions,
                    type: 'spring',
                    velocity: velocity.y,
                }
            );
        }
    };
 
    const snapTo = (index: number) => {
        const converted = convertSnappoints(snapPoints);
        if (!converted || !ref.current) return;
        const convertedPoint = converted[index];
        if (!convertedPoint) return;
 
        const elementBox = ref.current.getBoundingClientRect();
        // Thanks Claude Opus and this question on SO https://stackoverflow.com/questions/53866942/css-transform-matrix-to-pixel
        const style = window.getComputedStyle(ref.current);
        const transformMatrix = new DOMMatrixReadOnly(style.transform);
        const translate = { x: transformMatrix.e, y: transformMatrix.f };
        const base = {
            x: window.scrollX + elementBox.x - translate.x,
            y: window.scrollY + elementBox.y - translate.y,
        };
 
        setCurrentSnappointIndex(index);
        if (convertedPoint.x !== undefined) {
            animate(
                ref.current,
                { x: convertedPoint.x - base.x },
                {
                    ...springOptions,
                    type: 'spring',
                }
            );
        }
        if (convertedPoint.y !== undefined) {
            animate(
                ref.current,
                { y: convertedPoint.y - base.y },
                {
                    ...springOptions,
                    type: 'spring',
                }
            );
        }
    };
 
    const constraintsBoxRef = useRef<BoundingBox | null>(null);
 
    const [currentSnappointIndex, setCurrentSnappointIndex] = useState<null | number>(null);
 
    const dragProps: Partial<MotionProps> = {
        drag: direction === 'both' ? true : direction,
        onDragStart: (event, info) => {
            setCurrentSnappointIndex(null);
            onDragStart?.(event, info);
        },
        onDragEnd: onDragEndHandler,
        onMeasureDragConstraints(constraints) {
            constraintsBoxRef.current = constraints;
            onMeasureDragConstraints?.(constraints);
        },
 
        dragMomentum: false, // We'll handle this ourselves
        dragConstraints: constraints,
    };
 
    return {
        dragProps,
        snapTo,
        currentSnappointIndex
    };
};
Published at 24 June 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.