Circular menu's with react-laag and Framer Motion

18 October, 20199 min to read

Last week I introduced react-laag, a primitive to build things like tooltips, dropdown menu's and pop-overs in React. I've received a lot of positive reactions, which drives me to make this small library even better. As of this writing react-laag is at v1.2.0 which contains some bug fixes and small improvements. But more importantly, I've added a 13th anchor called "CENTER".

Ok cool, but what can we do with ... "CENTER"? Well, I don't know exactly, but I did found one use-case that I want to build with you today: a circular menu!

This is what we're going to build:

Alright, so to summarize we want:

  • A round button that triggers a menu when clicked upon
  • A couple of menu items (icons) that form a circular shape around the trigger-button
  • A enter and exit animation
  • to annotate the menu-item with a tooltip that describes to icon we're seeing

Tools

For this article we're going to use a couple of tools:

Ok, here we go...

The Button

We need to create a circular button with an icon in the center that will serve as our trigger.

// Button.js

import * as React from "react";
import styled, { css } from "styled-components";
import { Add } from "styled-icons/material/Add";

// some constants
const PRIMARY = "#2ea09b";
const PRIMARY_2 = "#268e89";
const BUTTON_SIZE = 56;

// Only apply the hover effect when the menu is closed
const buttonHover = css`
  &:hover {
    background-color: ${PRIMARY_2};
    transform: scale(1.03);
  }
`;

const ButtonBase = styled.button`
  width: ${BUTTON_SIZE}px;
  height: ${BUTTON_SIZE}px;
  color: white;
  border: none;
  background-color: ${p => (p.isOpen ? PRIMARY_2 : PRIMARY)};
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  outline: 0;
  cursor: pointer;
  transition: 0.2s ease-in-out;
  transform: scale(${p => (p.isOpen ? 1.03 : 1)});

  ${p => !p.isOpen && buttonHover}

  & svg {
    transition: 0.25s ease-in-out;
    transform: rotate(${p => (p.isOpen ? 45 : 0)}deg);
  }
`;

const Button = React.forwardRef(function Button(
  { style, className, isOpen, onClick },
  ref
) {
  return (
    <ButtonBase
      ref={ref}
      style={style}
      className={className}
      isOpen={isOpen}
      onClick={onClick}
    >
      <Add size={28} />
    </ButtonBase>
  );
});

export default Button;

Some interaction

I want react-laag to take care of all the positioning stuff. We are only interested in the fact that the menu (layer) should create a circle around the button (trigger). Let's create just that with the help of a 'dummy' menu;

// Example.js
import React from "react";
import { ToggleLayer } from "react-laag";

// Dummy Menu
// We are going to build the actual menu in a second
const DummyMenu = React.forwardRef(function DummyMenu({ style, onClick }, ref) {
  return (
    <div
      ref={ref}
      onClick={onClick}
      style={{
        ...style,
        width: 120,
        height: 120,
        borderRadius: "50%",
        backgroundColor: "rgba(0, 0, 255, 0.3)",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
      }}
    />
  );
});

function Example() {
  return (
    <ToggleLayer
      placement={{
        // we want to center our layer horizontally and vertically
        anchor: "CENTER",
      }}
      renderLayer={({ isOpen, layerProps, close }) => {
        // render our layer when the user clicks on the trigger
        return isOpen ? <DummyMenu {...layerProps} onClick={close} /> : null;
      }}
    >
      {({ triggerRef, toggle, isOpen }) => (
        // the menu button we've just created
        <Button ref={triggerRef} onClick={toggle} isOpen={isOpen} />
      )}
    </ToggleLayer>
  );
}

For now, we show a blue-ish background to get some sense of where te items are going te be positioned. Next, we need to place the menu-items along the border of the layer, but how are we going to achieve that? Well, we need a bit of math I'm afraid.

A bit of math

I'd like to propose a simple scale for dividing the menu-items evenly across the layer's border. The scale goes from 0 (0 degrees) to 1 (360 degrees). So, if I say 0.5, I mean halfway, or 180 degrees.

In order to render the menu-item on the correct spot, we need the calculate coordinates (x and y) relative to the layer's center. Here's a little function for you:

function getTransform(progress, radius) {
  const x = radius * Math.cos(Math.PI * 2 * (progress - 0.25));
  const y = radius * Math.sin(Math.PI * 2 * (progress - 0.25));

  return `translate(${x}px, ${y}px)`;
}

getTransform(0.5, 60); // gives us the `transform` value of a item at 180 degrees

Ok great, but that's just one menu-item, what about the other menu-items? Let say, we have 6 menu-items (totalItems) and we want to know the coordinates of item nr. 2 (index). We need to divide the index by the totalItems:

function getTransform(progress, radius, index, totalItems) {
  const value = (index / totalItems) * progress;

  const x = radius * Math.cos(Math.PI * 2 * (value - 0.25));
  const y = radius * Math.sin(Math.PI * 2 * (value - 0.25));

  return `translate(${x}px, ${y}px)`;
}

getTransform(1, 60, 0, 6); // item #1
getTransform(1, 60, 1, 6); // item #2
getTransform(1, 60, 2, 6); // item #3
getTransform(1, 60, 3, 6); // item #4
getTransform(1, 60, 4, 6); // item #5
getTransform(1, 60, 5, 6); // item #6

Right now, we get the x and y coordinates for each menu-item. But eventually, we want to animate it. We want all menu-items to start at the same location, and end up at the location they're supposed to be. So for each menu-item, progress === 0 means start location, progress === 1, means end location. As a finishing touch, we also want a bit of scaling:

function getTransform(progress, radius, index, totalItems) {
  const value = (index / totalItems) * progress;

  const x = radius * Math.cos(Math.PI * 2 * (value - 0.25));
  const y = radius * Math.sin(Math.PI * 2 * (value - 0.25));

  // min = 0.5  // max = 1.0  const scale = progress / 2 + 0.5;  return `translate(${x}px, ${y}px) scale(${scale})`;}

Here's a CodeSandbox to illustrate what we got so far:

The MenuItem

Let start by defining some styles:

// some constants
const ITEM_SIZE = 36;
const PRIMARY = "#2ea09b";
const PRIMARY_2 = "#268e89";
const BORDER = "#dadada";
const TEXT = "#656565";
const RADIUS = 60;

// We're wrapping a `motion.div` for animations later on
const TooltipBox = styled(motion.div)`
  background-color: #333;
  color: white;
  font-size: 12px;
  padding: 4px 8px;
  line-height: 1.15;
  border-radius: 3px;
`;

// We're wrapping a `motion.div` for animations later on
const Circle = styled(motion.div)`
  position: absolute;
  width: ${ITEM_SIZE}px;
  height: ${ITEM_SIZE}px;
  background-color: white;
  border-radius: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px solid ${BORDER};
  box-shadow: 1px 1px 6px 0px rgba(0, 0, 0, 0.1);
  cursor: pointer;
  transition: box-shadow 0.15s ease-in-out, border 0.15s ease-in-out;
  color: ${TEXT};
  pointer-events: all;
  will-change: transform;

  & svg {
    transition: 0.15s ease-in-out;
  }

  &:hover {
    box-shadow: 1px 1px 10px 0px rgba(0, 0, 0, 0.15);
    color: ${PRIMARY};

    & svg {
      transform: scale(1.15);
    }
  }
`;

Now that we got some basic material to work with, we can create the actual MenuItem. We want to show a tooltip when the user hovers a menu-item. I'm not gonna dive into that though. If you want to learn more about creating tooltips with react-laag, I suggest you read my previous post here.

function MenuItem({ Icon, onClick, label, index, totalItems }) {
  // Helper for determining when to show the tooltip-box
  const [isOpen, bind] = useHover({ delayEnter: 300, delayLeave: 100 });

  return (
    <ToggleLayer
      isOpen={isOpen}
      fixed
      placement={{
        anchor: "TOP_CENTER",
        autoAdjust: true,
        scrollOffset: 16,
        triggerOffset: 6,
      }}
      renderLayer={({ isOpen, layerProps }) => {
        return (
          <AnimatePresence>
            {isOpen && (
              <TooltipBox
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                {...layerProps}
              >
                {label}
              </TooltipBox>
            )}
          </AnimatePresence>
        );
      }}
    >
      {({ triggerRef }) => (
        <Circle
          ref={triggerRef}
          onClick={onClick}
          {...bind}
          style={{
            // Apply the math we did earlier
            transform: getTransform(value, RADIUS, index, totalItems),
          }}
        >
          <Icon size={20} />
        </Circle>
      )}
    </ToggleLayer>
  );
}

The Menu

Time to replace the DummyMenu with the actual Menu. Essentially it serves as a wrapper for our MenuItems:

const MenuBase = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: ${RADIUS * 2}px;
  height: ${RADIUS * 2}px;
  pointer-events: none;
  border-radius: 50%;
`;

const Menu = React.forwardRef(function Menu(
  { style, close, items, onClick },
  ref
) {
  return (
    <MenuBase ref={ref} style={style} onClick={close}>
      {items.map((item, index) => (
        <MenuItem
          key={index}
          Icon={item.Icon}
          label={item.label}
          onClick={onClick}
          index={index}
          totalItems={items.length}
        />
      ))}
    </MenuBase>
  );
});

Animations 🎉!!!

Here comes the fun part: animations. We want to animate the menu-items when the layer opens and closes. Fortunately, framer-motion provides a handy tool that manages the appearance and disappearance of elements. It's called AnimatePresence. Let's integrate that into our Example:

/**
 * Icons
 */

import { Image } from "styled-icons/boxicons-regular/Image";
import { PlayCircle as Video } from "styled-icons/boxicons-regular/PlayCircle";
import { Music } from "styled-icons/boxicons-solid/Music";
import { File } from "styled-icons/boxicons-regular/File";
import { LocationOn as Location } from "styled-icons/material/LocationOn";
import { Code } from "styled-icons/boxicons-regular/Code";

/**
 * Components
 */

import Button from "./Button";
import Menu from "./Menu";

/**
 * Main
 */

function Example() {
  return (
    <ToggleLayer
      ResizeObserver={ResizeObserver}
      placement={{
        anchor: "CENTER",
      }}
      renderLayer={({ isOpen, layerProps, close }) => {
        return (
          <AnimatePresence>
            {isOpen && (
              <Menu
                {...layerProps}
                close={close}
                onClick={value => console.log(`You've clicked: ${value}`)}
                items={[
                  { Icon: Image, value: "image", label: "Image" },
                  { Icon: Video, value: "video", label: "Video" },
                  { Icon: Music, value: "music", label: "Music" },
                  { Icon: File, value: "file", label: "File" },
                  { Icon: Location, value: "location", label: "Location" },
                  { Icon: Code, value: "code", label: "Code" },
                ]}
              />
            )}
          </AnimatePresence>
        );
      }}
    >
      {({ triggerRef, toggle, isOpen }) => (
        <Button ref={triggerRef} onClick={toggle} isOpen={isOpen} />
      )}
    </ToggleLayer>
  );
}

By wrapping our Menu within <AnimatePresence/>, motion elements get access to an extra prop called exit. And since our <MenuItem /> is in fact a motion.div we can now configure how the element should behave / animate when the component enters or exits. Let's go back to the MenuItem real quick:

function MenuItem({ Icon, onClick, label, index, totalItems }) {
  const [isOpen, bind] = useHover({ delayEnter: 300, delayLeave: 100 });

  return (
    <ToggleLayer
    // rest of props skipped for brevity...
    >
      {({ triggerRef }) => (
        <Circle
          ref={triggerRef}
          onClick={onClick}
          {...bind}
          // define our starting point
          initial={{ x: 0, opacity: 0 }}
          // define where we want to animate to
          animate={{ x: 1, opacity: 1 }}
          // define what the animating values should be
          // when the component exits
          exit={{ x: 0, opacity: 0 }}
          // Little hack to 'interpolate' our transform
          // between 0 and 1
          transformTemplate={({ x }) => {
            const progress = parseFloat(x.replace("px", ""));
            // the math we did earlier
            return getTransform(progress, RADIUS, index, totalItems);
          }}
          transition={{
            // create a little staggering effect
            delay: index * 0.025,
            type: "spring",
            stiffness: 600,
            damping: 50,
          }}
        >
          <Icon size={20} />
        </Circle>
      )}
    </ToggleLayer>
  );
}

Conclusion

And there you have it: a animated circular menu in about ~200 lines of code. Be sure to checkout the CodeSandbox for the entire example.

There's a lot more react-laag can do than simply positioning the layer centered though. We could for example re-position the layer when it doesn't fit the screen entirely. Here's an example (sandbox):

Thanks for reading!

Learn more about the project on Github, or check out the docs.

Follow me on Twitter to receive the latest updates!