paint-brush
Building a Stateful Bottom Sheet Component in Reactby@lastcallofsummer
3,920 reads
3,920 reads

Building a Stateful Bottom Sheet Component in React

by Olga StogovaJuly 12th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In this article, we walk you through the process of building a customizable, stateful bottom sheet component using React. We use the Framer Motion library to provide the animated behavior of the bottom sheet. The full source code for this tutorial is available on GitHub(https://github.com/helgastogova/react-stateful-bottom-sheet)
featured image - Building a Stateful Bottom Sheet Component in React
Olga Stogova HackerNoon profile picture


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

.

Harnessing the Power of Framer Motion

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.

Simple Animations

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.

Variants for Complex Animations

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).

Gestures and Interactions

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.

Leveraging Framer Motion in Our Bottom Sheet Component

In our Bottom Sheet Component, we made use of two primary elements from the Framer Motion library — the motion component and the useMotionValue hook.

Framer Motion Component

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.

Framer Motion Hook

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.

The Drag End Handler

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.

Embarking on the Customizable Bottom Sheet Component Journey

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!