Circular menu's with react-laag and Framer Motion
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:
- react-laag for the layer stuff
- styled-components for the styles
- styled-icons for the icons
- framer-motion for the animations
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!