frontend.fyi

Step-by-Step Guide: Creating a Foldable Map with Framer Motion

Using the drag API of Framer Motion, we can create a realistic looking foldable map component.

🧑‍💻 🎥
... Almost ready!

When I came across this tweet by Sam where he creates a super realistically looking foldable map, I knew I had to try and recreate this with Framer Motion. That’s exactly what we’ll be doing in this tutorial!

Making a div we can fold in three

The first thing we have to do, is make sure we can fold the image in three. For that we’re going to create a simple 3 column grid.

1
<div className="grid grid-cols-3">
2
<div className="bg-black" />
3
<div className="bg-white" />
4
<div className="bg-black" />
5
</div>

To turn this three column grid into a map, we’re gonna add the same image as a background image three times — while changing its background position.

1
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3">
2
<div className="bg-[url(/article-images/map.webp)]" />
3
<div className="bg-[url(/article-images/map.webp)] bg-center" />
4
<div className="bg-[url(/article-images/map.webp)] bg-right" />
5
</div>

The tailwind classes bg-center and bg-right then add a background-position: center and background-position: right respectively. These positions then make sure we see the correct part of the image.

But as you see, the image isn’t lining up at all. Reason for that being that the image tries to fit in the 1/3 of width. Because of that the aspect ratio is different than when it would be 100% width.

Luckily we can easily fix that by using background-size: 300%, or using the tailwind class bg-[size:300%]. This ensure we make the image 3 times it’s size — so the 1/3 column grows to the size of 3-thirds, making it line up exactly.

1
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3">
2
<div className="bg-[url(/article-images/map.webp)] bg-[size:300%]" />
3
<div className="bg-[url(/article-images/map.webp)] bg-center bg-[size:300%]" />
4
<div className="bg-[url(/article-images/map.webp)] bg-right bg-[size:300%]" />
5
</div>

Moving the three parts over top of each other

In the folded state of the map, the outer two sections should be moved inwards, to overlap the center section. By adding a transform: translateX(100%) we can move the sections exactly 1-third of the way inward. In tailwind we can use the translate-x-full classname for the same result.

1
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3">
2
<div className="translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%]" />
3
<div className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center" />
4
<div className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
5
</div>

Adding some dragging behavior

Now we have these three separate divs that show part of the map, we also have these two outside parts that we can move outward to unfold the map.

In order to drag the map, it might feel natural to add the dragging behavior to the top-most section of the map. The downside of that is you can only drag certain parts of the map, or need to make all three parts draggable.

Because of that, an easier approach would be to overlay a draggable div of which we get the dragged distance, and apply that dragged distance to the map parts.

We’re gonna add this draggable behavior by using Framer Motion’s Drag API.

1
<div className="grid">
2
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3 [grid-area:1/1]">
3
<div className="translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%]" />
4
<div className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center" />
5
<div className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
6
</div>
7
<motion.div
8
drag="x"
9
className="bg-white-opaque [grid-area:1/1]"
10
></motion.div>
11
</div>

In the example above there’s 3 important parts:

  1. We wrapped everything inside another div with display: grid
  2. Because of that we can use grid-area: 1/1; to overlay multiple divs on top of each other.
  3. Lastly you notice we added a second div inside that grid element, a motion.div. This motion.div uses Framer Motion’s drag API by adding the prop drag="x", which enables dragging over the x-axis. Try it!

Using the dragged distance to manipulate the map

Framer Motion allows us to get the dragged distance and use this as an input for any other animation. For that we need to create a custom motion value.

1
import { useMotionValue, motion } from "framer-motion";
2
3
export const FoldableMap = () => {
4
const dragX = useMotionValue(0);
5
6
return (
7
<div className="grid">
8
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3 [grid-area:1/1]">
9
<div className="translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%]" />
10
<div className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center" />
11
<div className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
12
</div>
13
<motion.div
14
drag="x"
15
style={{ x: dragX }}
16
className="bg-white-opaque [grid-area:1/1]"
17
></motion.div>
18
</div>
19
);
20
}

We create this custom motion value by using the useMotionValue hook, and then pass its value onto the x property inside the style prop. Framer Motion will then take care of updating this motion value when you drag, while also moving the draggable area at the same time.

The moveable draggable area

Right now the draggable area moves as soon as you drag it. This results in the user not being able to drag over the map anymore once it’s moved to far out. There’s two things we can do to improve this behavior.

Adding dragConstraints

By adding dragConstrains we can limit the direction and distance a user can drag.

1
<motion.div
2
drag="x"
3
style={{ x: dragX }}
4
dragConstraints={{ left: 0, right: 300 }}
5
className="bg-white-opaque [grid-area:1/1]"
6
></motion.div>

This property makes sure the user can only move the div 300 pixels to the right, and not at all to the left. Once it reaches these points the div will bounce back into place.

This is already way better. But still there’s now an area where we can’t drag over the image anymore, because our rectangle isn’t overtop of it anymore. For that there’s a kind of hidden property in Framer Motion we can use: _dragX.

1
<motion.div
2
drag="x"
3
_dragX={dragX}
4
dragConstraints={{ left: 0, right: 300 }}
5
className="bg-white-opaque [grid-area:1/1]"
6
></motion.div>

Using _dragX over the style prop ensures the div won’t move at all anymore. It does however still update our dragX motion value, which we can still use to animate other parts of the map.

Manipulate the map with this drag value

So how do we move the map based on this drag motion value? Framer Motion to the rescue again! We can use the useTransform hook to create a new motion value out of another one.

1
import { useMotionValue, motion, useTransform } from "framer-motion";
2
3
export const FoldableMap = () => {
4
const dragX = useMotionValue(0);
5
const xLeftSection = useTransform(dragX, [0, 300], ["100%", "0%"])
6
7
return (
8
<div className="grid">
5 collapsed lines
9
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3 [grid-area:1/1]">
10
<div className="translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%]" />
11
<div className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center" />
12
<div className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
13
</div>
14
<motion.div
15
drag="x"
16
_dragX={dragX }
17
className="bg-white-opaque [grid-area:1/1]"
18
></motion.div>
19
</div>
20
);
21
}

The xLeftSection is now a variable that has a range from 100% to 0%, where the 100% value starts the moment 0 pixels are dragged, all the way till 0% for 300 pixels dragged.

We can then take this variable and add use it as a translateX on our map section, instead of the classname translate-x-full.

For the right section we should move it from -100% to 0%.

Finally it’s also really important that we give the draggable area a higher z-index, because otherwise transformed elements will be placed over top of the div, and you still won’t be able to drag it.

1
import { useMotionValue, motion, useTransform } from "framer-motion";
2
3
export const FoldableMap = () => {
4
const dragX = useMotionValue(0);
5
const xLeftSection = useTransform(dragX, [0, 300], ["100%", "0%"]);
6
const xRightSection = useTransform(dragX, [0, 300], ["-100%", "0%"]);
7
8
return (
5 collapsed lines
9
<div className="grid">
10
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3 [grid-area:1/1]">
11
<motion.div style={{ x: xLeftSection }} className="bg-[url(/article-images/map.webp)] bg-[size:300%]" />
12
<div className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center" />
13
<motion.div style={{ x: xRightSection }} className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
14
</div>
15
<motion.div
16
drag="x"
17
_dragX={dragX }
18
className="relative z-10 bg-white-opaque [grid-area:1/1]"
19
></motion.div>
20
</div>
21
);
22
}

Transforming more parts of the map

Besides folding out the outer parts of the map, we can animate many more values based on the drag. We can for example scale the center part of the map, to make it feel like it’s pulled outwards.

For that we’re again using a useTransform, but this time only transforming the scale from a drag value starting a 150 pixels. That is the moment where the outer two parts are dragged outwards for 50%, and thus the center part starts to reveal.

1
import { useMotionValue, motion, useTransform } from "framer-motion";
2
3
export const FoldableMap = () => {
3 collapsed lines
4
const dragX = useMotionValue(0);
5
const xLeftSection = useTransform(dragX, [0, 300], ["100%", "0%"]);
6
const xRightSection = useTransform(dragX, [0, 300], ["-100%", "0%"]);
7
const centerScale = useTransform(dragX, [150, 300], [0.2, 1]);
8
9
return (
10
<div className="grid">
11
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3 [grid-area:1/1]">
12
<motion.div style={{ x: xLeftSection }} className="bg-[url(/article-images/map.webp)] bg-[size:300%]" />
13
<motion.div
14
style={{ scaleX: centerScale }}
15
className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center"
16
/>
17
<motion.div style={{ x: xRightSection }} className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
18
</div>
5 collapsed lines
19
<motion.div
20
drag="x"
21
_dragX={dragX }
22
className="relative z-10 bg-white-opaque [grid-area:1/1]"
23
></motion.div>
24
</div>
25
);
26
}

Make the map auto-open

Right now when dragging the map, it stops halfway if you stop dragging it. It would look so much better when it would automatically open or close depending on how far you’ve opened it.

For that we can use the dragTransition prop on the draggable div. This prop allows us to modify end state of the drag when the user stops dragging.

As an argument to the modifyTarget function we get the current X value, and can then return a new value. In this case we return 300 when the target is over 150, and 0 otherwise. This makes the map either fully open or fully closed.

1
import { useMotionValue, motion, useTransform } from "framer-motion";
2
3
export const FoldableMap = () => {
4 collapsed lines
4
const dragX = useMotionValue(0);
5
const xLeftSection = useTransform(dragX, [0, 300], ["100%", "0%"]);
6
const xRightSection = useTransform(dragX, [0, 300], ["-100%", "0%"]);
7
const centerScale = useTransform(dragX, [150, 300], [0.2, 1]);
8
9
return (
10
<div className="grid">
8 collapsed lines
11
<div className="grid aspect-video w-[500px] max-w-full grid-cols-3 [grid-area:1/1]">
12
<motion.div style={{ x: xLeftSection }} className="bg-[url(/article-images/map.webp)] bg-[size:300%]" />
13
<motion.div
14
style={{ scaleX: centerScale }}
15
className="bg-[url(/article-images/map.webp)] bg-[size:300%] bg-center"
16
/>
17
<motion.div style={{ x: xRightSection }} className="-translate-x-full bg-[url(/article-images/map.webp)] bg-[size:300%] bg-right" />
18
</div>
19
<motion.div
20
drag="x"
21
_dragX={dragX}
22
dragTransition={{
23
modifyTarget: (target) => {
24
return target > 150 ? 300 : 0;
25
},
26
timeConstant: 45,
27
}}
28
className="relative z-10 bg-white-opaque [grid-area:1/1]"
29
></motion.div>
30
</div>
31
);
32
}

End result

The end result of this tutorial is a lot more detailed. So make sure to also check out the video and playground at the top of this article to see the full implementation and all its small details.

Pick your favorite spot ☝️