Circle Menu
A circular navigation menu that expands items in a circle around a central button with smooth animations and hover effects.
Install dependencies
npm i framer-motion
Utility function
Create a file lib/utils.ts and paste this code
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Component
Create a file circle-menu.tsx in your components folder and paste this code
'use client';
import { AnimatePresence, motion, useAnimationControls } from 'framer-motion';
import { Menu, X } from 'lucide-react';
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { useRouter } from 'next/navigation';
const CONSTANTS = {
itemSize: 48,
containerSize: 250,
openStagger: 0.02,
closeStagger: 0.07
};
const STYLES: Record<string, Record<string, string>> = {
trigger: {
container:
'rounded-full flex items-center bg-[#27272A] justify-center border border-white/10 cursor-pointer outline-none ring-0 hover:brightness-125 transition-all duration-100 z-50',
active: 'bg-[#27272A]'
},
item: {
container:
'rounded-full flex items-center justify-center absolute bg-zinc-100 text-black hover:bg-white cursor-pointer',
label: 'text-xs text-black absolute top-full left-1/2 -translate-x-1/2 text-white mt-1'
}
};
const pointOnCircle = (i: number, n: number, r: number, cx = 0, cy = 0) => {
const theta = (2 * Math.PI * i) / n - Math.PI / 2;
const x = cx + r * Math.cos(theta);
const y = cy + r * Math.sin(theta) + 0;
return { x, y };
};
interface MenuItemProps {
icon: React.ReactNode;
label: string;
href: string;
index: number;
totalItems: number;
isOpen: boolean;
}
const MenuItem = ({ icon, label, href, index, totalItems, isOpen }: MenuItemProps) => {
const { x, y } = pointOnCircle(index, totalItems, CONSTANTS.containerSize / 2);
const [hovering, setHovering] = useState(false);
const router = useRouter();
return (
<motion.button
onClick={() => router.push(href)}
animate={{
x: isOpen ? x : 0,
y: isOpen ? y : 0
}}
whileHover={{
scale: 1.1,
transition: {
duration: 0.1,
delay: 0
}
}}
transition={{
delay: isOpen ? index * CONSTANTS.openStagger : index * CONSTANTS.closeStagger,
type: 'spring',
stiffness: 300,
damping: 30
}}
style={{
height: CONSTANTS.itemSize - 2,
width: CONSTANTS.itemSize - 2
}}
className={STYLES.item.container}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{icon}
{hovering && <p className={STYLES.item.label}>{label}</p>}
</motion.button>
);
};
interface MenuTriggerProps {
setIsOpen: (isOpen: boolean) => void;
isOpen: boolean;
itemsLength: number;
closeAnimationCallback: () => void;
openIcon?: React.ReactNode;
closeIcon?: React.ReactNode;
}
const MenuTrigger = ({
setIsOpen,
isOpen,
itemsLength,
closeAnimationCallback,
openIcon,
closeIcon
}: MenuTriggerProps) => {
const animate = useAnimationControls();
const shakeAnimation = useAnimationControls();
const scaleTransition = Array.from({ length: itemsLength - 1 })
.map((_, index) => index + 1)
.reduce((acc, _, index) => {
const increasedValue = index * 0.15;
acc.push(1 + increasedValue);
return acc;
}, [] as number[]);
const closeAnimation = async () => {
shakeAnimation.start({
translateX: [0, 2, -2, 0, 2, -2, 0],
transition: {
duration: CONSTANTS.closeStagger,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop'
}
});
for (let i = 0; i < scaleTransition.length; i++) {
await animate.start({
height: Math.min(
CONSTANTS.itemSize * scaleTransition[i],
CONSTANTS.itemSize + CONSTANTS.itemSize / 2
),
width: Math.min(
CONSTANTS.itemSize * scaleTransition[i],
CONSTANTS.itemSize + CONSTANTS.itemSize / 2
),
backgroundColor: `color-mix(in srgb, #27272A ${Math.max(100 - i * 10, 40)}%, white)`,
transition: {
duration: CONSTANTS.closeStagger / 2,
ease: 'linear'
}
});
if (i !== scaleTransition.length - 1) {
await new Promise((resolve) => setTimeout(resolve, CONSTANTS.closeStagger * 1000));
}
}
shakeAnimation.stop();
shakeAnimation.start({
translateX: 0,
transition: {
duration: 0
}
});
animate.start({
height: CONSTANTS.itemSize,
width: CONSTANTS.itemSize,
backgroundColor: '#27272A',
transition: {
duration: 0.1,
ease: 'backInOut'
}
});
};
return (
<motion.div animate={shakeAnimation} className="z-50">
<motion.button
animate={animate}
style={{
height: CONSTANTS.itemSize,
width: CONSTANTS.itemSize
}}
className={cn(STYLES.trigger.container, isOpen && STYLES.trigger.active)}
onClick={() => {
if (isOpen) {
setIsOpen(false);
closeAnimationCallback();
closeAnimation();
} else {
setIsOpen(true);
}
}}
>
<AnimatePresence mode="popLayout">
{isOpen ? (
<motion.span
key="menu-close"
initial={{
opacity: 0,
filter: 'blur(10px)'
}}
animate={{
opacity: 1,
filter: 'blur(0px)'
}}
exit={{
opacity: 0,
filter: 'blur(10px)'
}}
transition={{
duration: 0.2
}}
>
{closeIcon}
</motion.span>
) : (
<motion.span
key="menu-open"
initial={{
opacity: 0,
filter: 'blur(10px)'
}}
animate={{
opacity: 1,
filter: 'blur(0px)'
}}
exit={{
opacity: 0,
filter: 'blur(10px)'
}}
transition={{
duration: 0.2
}}
>
{openIcon}
</motion.span>
)}
</AnimatePresence>
</motion.button>
</motion.div>
);
};
const CircleMenu = ({
items,
openIcon = <Menu size={18} className="text-white" />,
closeIcon = <X size={18} className="text-white" />
}: {
items: Array<{ label: string; icon: React.ReactNode; href: string }>;
openIcon?: React.ReactNode;
closeIcon?: React.ReactNode;
}) => {
const [isOpen, setIsOpen] = useState(false);
const animate = useAnimationControls();
const closeAnimationCallback = async () => {
await animate.start({
rotate: -360,
filter: 'blur(1px)',
transition: {
duration: CONSTANTS.closeStagger * (items.length + 2),
ease: 'linear'
}
});
await animate.start({
rotate: 0,
filter: 'blur(0px)',
transition: {
duration: 0
}
});
};
return (
<div
style={{
width: CONSTANTS.containerSize,
height: CONSTANTS.containerSize
}}
className="relative flex items-center justify-center place-self-center"
>
<MenuTrigger
setIsOpen={setIsOpen}
isOpen={isOpen}
itemsLength={items.length}
closeAnimationCallback={closeAnimationCallback}
openIcon={openIcon}
closeIcon={closeIcon}
/>
<motion.div
animate={animate}
className={cn('absolute inset-0 z-0 flex items-center justify-center')}
>
{items.map((item, index) => {
return (
<MenuItem
key={`menu-item-${index}`}
icon={item.icon}
label={item.label}
href={item.href}
index={index}
totalItems={items.length}
isOpen={isOpen}
/>
);
})}
</motion.div>
</div>
);
};
export default CircleMenu;
Usage
<div className="w-full h-full flex items-center justify-center">
<CircleMenu
items={[
{ label: 'Home', icon: <Home size={16} />, href: '' },
{ label: 'Projects', icon: <Projector size={16} />, href: '' },
{ label: 'Skills', icon: <DollarSign size={16} />, href: '' },
{ label: 'Articles', icon: <BookOpen size={16} />, href: '' },
{ label: 'Lab', icon: <FlaskConical size={16} />, href: '' },
{ label: 'About', icon: <User size={16} />, href: '' },
{ label: 'Contact', icon: <Mail size={16} />, href: '' }
]}
/>
</div>