Circle Menu
Dialog Form
Dominoes List Scroll
Dominoes Scroll Indicator
Eagle Vision
Electric AI Input
File Input
Flip Scroll
Glowing Scroll Indicator
Horizontal Scroll
Icon Wheel
Image Pile
Interactive CTA
Interactive Folder
Interest Picker
Jelly Loader
Leave Rating
Mask Cursor Effect
Magnet Tabs
Masonry Grid
OTP Input
Photo Gallery
Pixelated Carousel
Rolling Ball Scroll Indicator
Rubik Cube
Sidebar
Sine Wave
Skeumorphic Music Card
Social Media Card
Stacked Input Form
Stack Scroll
Trading Card
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>