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.div8 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:
- We wrapped everything inside another div with
display: grid
- Because of that we can use
grid-area: 1/1;
to overlay multiple divs on top of each other. - Lastly you notice we added a second div inside that grid element, a
motion.div
. Thismotion.div
uses Framer Motion’s drag API by adding the propdrag="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.
1import { useMotionValue, motion } from "framer-motion";2
3export 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.div14 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.div2 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.div2 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.
1import { useMotionValue, motion, useTransform } from "framer-motion";2
3export 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.div15 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.
1import { useMotionValue, motion, useTransform } from "framer-motion";2
3export 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.div16 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.
1import { useMotionValue, motion, useTransform } from "framer-motion";2
3export 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.div14 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.div20 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.
1import { useMotionValue, motion, useTransform } from "framer-motion";2
3export 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.div14 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.div20 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 ☝️