Creating interactive, dynamic, and responsive user interfaces is a fundamental part of front-end development. In the realm of mobile web applications, a pattern that frequently makes an appearance is the bottom sheet component — a UI element that glides up from the bottom of the screen to unveil more content or interactions.
In this article, we walk you through the process of building a customizable, stateful bottom sheet component using React, with a special focus on the powerful animation library, “Framer Motion.”
Before we start, the full source code for this tutorial is available on GitHub, and the published package can be found on npm. Feel free to use it in your applications and tailor it to your needs. You can also check out a live demo
.Framer Motion is a popular open-source library that brings your React applications to life with smooth, natural animations. Its API is designed to be simple and intuitive while offering a rich set of features for animations and interactions. Let’s look at some common use cases for Framer Motion.
Animating a component in Framer Motion is as simple as swapping out the usual HTML or React tag with a motion
prefixed tag. For example, to animate a div
you would use motion.div
.
Framer Motion components can then be animated using the animate
prop, which accepts an object with CSS properties and values. For example, you can fade in an element by changing its opacity:
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
Hello, world!
</motion.div>
In this example, the motion.div
component will start with an opacity of 0
and animate to an opacity of 1
, creating a fade-in effect.
For more complex animations, Framer Motion offers variants
. Variants allow you to predefine a set of animation states and switch between them. This comes in handy when creating animations that have multiple stages or need to be reused across multiple components.
const variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}
<motion.div
variants={variants}
initial="hidden"
animate="visible"
>
Hello, world!
</motion.div>
In this example, the motion.div
starts in the "hidden" state (with an opacity of 0
) and animates to the "visible" state (with an opacity of 1
).
Framer Motion also offers a robust set of controls for handling gestures like drag, tap, hover, and pinch. These can be enabled with simple props and come with built-in animations.
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
>
I'm a draggable element!
</motion.div>
These are just a few of the many ways you can use Framer Motion to add animations and enhance interactivity in your React applications. It offers a great balance between simplicity of use and depth of capability, making it a versatile choice for any developer’s toolkit.
You can find out more examples on the Framer Motion official site.
In our Bottom Sheet Component, we made use of two primary elements from the Framer Motion library — the motion
component and the useMotionValue
hook.
We used the motion
component to provide the animated behavior of the bottom sheet. Instead of the usual div
tag, we used motion.div
for the wrapping component of our Bottom Sheet.
return (
<motion.div
drag="y"
className={cx(s.root, rootClassName)}
style={{
height: height,
y
}}
dragConstraints={{ top: 0, bottom: 0 }}
onDragEnd={handleDragEnd}
ref={componentRef}
>
...
</motion.div>
);
Here, we’ve used several properties of the motion
component:
drag="y"
: This enables dragging of the element along the y-axis.style={{ height: height, y }}
: This applies the animated values of height and y position to the style of the component.dragConstraints={{ top: 0, bottom: 0 }}
: This restricts the drag motion within the given constraints. In this case, the Bottom Sheet can't be dragged outside of its container.onDragEnd={handleDragEnd}
: This is a callback that is executed when the drag event ends. We're using this to decide whether to open or close the Bottom Sheet based on the drag direction.The useMotionValue
hook from Framer Motion provides a way to create a motion value that can be used in animations. In our Bottom Sheet Component, we've used useMotionValue
to control the y
position of our bottom sheet.
const y = useMotionValue(0);
When the Bottom Sheet is dragged, the value of y
changes dynamically, and the component re-renders, giving us a smooth dragging animation.
We’ve also created a handler function for the onDragEnd
event that uses the information about the drag event (provided by Framer Motion) to decide whether to open or close the Bottom Sheeta and sets the height of the BottomSheet based on the direction of the drag.
const handleDragEnd = (
_: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo
) => {
setHeight(info.offset.y < 0 ? fullHeight : compactHeight);
setOpen(info.offset.y < 0);
};
Here, info.offset.y
gives us the total distance dragged in pixels. If the value is negative, it means the Bottom Sheet has been dragged upwards, and hence we set the height to fullHeight
and set isOpen
to true
. Conversely, if the Bottom Sheet is dragged downwards, info.offset.y
will be positive, and we set the height to compactHeight
and isOpen
to false
.
In summary, the combination of Framer Motion’s motion
component and useMotionValue
hook provided us with a straightforward and intuitive way to animate our Bottom Sheet Component and make it interactive.
Our adventure into the construction of a stateful Bottom Sheet component begins with the acceptance of an array of props that give us the freedom to personalize its behavior and appearance. It kick-starts by initializing the required state variables and arranging a ref to manage clicks external to the component:
export const BottomSheet: React.FC<BottomSheetProps> = ({
children,
rootClassName,
wrapperClassName,
lineClassName,
contentClassName,
compactHeight = "auto",
fullHeight = "90vh",
onClickOutside,
closeOnClickOutside = true
}) => {
const componentRef = useRef<HTMLDivElement | null>(null);
const [height, setHeight] = useState<string>(compactHeight);
const [isOpen, setOpen] = useState<boolean>(false);
const y = useMotionValue(0);
...
};
Next, we step into the world of useEffect
hooks, which checks if the bottom sheet is open and sets the height correspondingly:
useEffect(() => {
if (!isOpen) setHeight(compactHeight);
}, [isOpen, setHeight, compactHeight]);
Taking advantage of the useClickOutside
hook, it takes an array of refs and a function as parameters. The function is called whenever a click is detected outside the elements referred to by the refs. In our case, we pass the componentRef
and a function that checks if closeOnClickOutside
is true. If so, the bottom sheet is closed when a click outside is detected.
useClickOutside([componentRef], () => {
onClickOutside?.();
if (closeOnClickOutside) {
setOpen(false);
}
});
For a detailed overview, the complete code of the useClickOutside
hook looks like this:
import React, { useEffect } from "react";
export function useClickOutside<T extends HTMLElement>(
refs: React.MutableRefObject<T | null>[],
callback: (event: MouseEvent) => void,
): void {
const handleClick = (e: MouseEvent): void => {
if (!refs.some((ref) => ref.current && ref.current.contains(e.target as Node))) {
return callback(e);
}
};
useEffect(() => {
document.addEventListener("click", handleClick, true);
return () => {
document.removeEventListener("click", handleClick, true);
};
});
}
Finally, we return the JSX for our BottomSheet, leveraging the motion
component from framer-motion
to incorporate the drag functionality.
return (
<motion.div
drag="y"
className={cx(s.root, rootClassName)}
style={{
height: height,
y
}}
dragConstraints={{ top: 0, bottom: 0 }}
onDragEnd={handleDragEnd}
ref={componentRef}
>
<div className={cx(s.wrapper, wrapperClassName)}>
<div className={s.line}>
<div className={cx(s.innerLine, lineClassName)}></div>
</div>
<div className={cx(s.content, contentClassName)}>{Children}</div>
</div>
</motion.div>
);
With this, our journey of building a stateful Bottom Sheet component in React comes to an end. As seen, the animation capabilities of Framer Motion were integral to providing the dynamic, responsive, and smooth UI that characterizes a Bottom Sheet component. As developers, it’s our job to craft interactive, engaging, and user-friendly interfaces, and tools like Framer Motion make this task much more enjoyable and efficient.
Happy coding!