Systaliko UI
blockanimated-menusource: ok · hit
Animated Menu
Animated menu, with orchestrated children animation, fully customizable.
npx shadcn add "https://systaliko-ui.vercel.app/r/animated-menu.json"free
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files1 · source cached 10/14/2026, 3:04:01 PM
registry/blocks/animated-menu/index.tsx
'use client';
import { cn } from '@/lib/utils';
import { cva, VariantProps } from 'class-variance-authority';
import {
AnimatePresence,
HTMLMotionProps,
motion,
MotionConfig,
Variants,
} from 'motion/react';
import React from 'react';
export function useClickOutside<T extends HTMLElement>(
callback: () => void,
enabled = true,
) {
const ref = React.useRef<T>(null);
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
React.useEffect(() => {
if (!enabled) return;
const handleClick = (event: MouseEvent) => {
const element = ref.current;
if (!element) return;
if (!element.contains(event.target as Node)) {
callbackRef.current();
}
};
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [enabled]);
return ref;
}
const menuListVariants = {
open: {
width: 320,
height: 420,
transition: { duration: 0.75, ease: [0.76, 0, 0.24, 1] },
},
close: {
width: 80,
height: 32,
transition: { duration: 0.75, delay: 0.2, ease: [0.76, 0, 0.24, 1] },
},
} as Variants;
const itemVariants = {
initial: {
opacity: 0,
},
enter: (i: number) => ({
opacity: 1,
transition: {
delay: 0.55 + i * 0.1,
duration: 0.75,
},
}),
exit: (i: number) => ({
opacity: 0,
transition: {
delay: 0.25 + -i * 0.1,
},
}),
} as Variants;
interface AnimatedMenuContextValue {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
interface AnimatedMenuItemProps extends HTMLMotionProps<'div'> {
order?: number;
variants?: Variants;
}
interface AnimatedMenuButtonLabelProps
extends React.HTMLAttributes<HTMLDivElement> {
closeLabel?: string;
openLabel?: string;
}
const AnimatedMenuContext = React.createContext<
AnimatedMenuContextValue | undefined
>(undefined);
function useAnimatedMenuContext() {
const context = React.useContext(AnimatedMenuContext);
if (context === undefined) {
throw new Error(
'useAnimatedMenuContext must be used within an AnimatedMenuProvider',
);
}
return context;
}
export function AnimatedMenu({
className,
...props
}: React.ComponentProps<'div'>) {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
return (
<AnimatedMenuContext.Provider value={{ isOpen, setIsOpen }}>
<div className={cn('relative', className)} {...props} />
</AnimatedMenuContext.Provider>
);
}
const buttonIconVariants = cva(
'flex flex-col gap-1.5 justify-center items-center p-1 relative *:bg-current',
{
variants: {
size: {
sm: '*:w-4 *:h-[1.5px] *:origin-[17%]',
md: '*:w-6 *:h-0.5 *:origin-[25%]',
lg: '*:w-8 *:h-0.5 *:origin-[33%]',
xl: '*:w-10 *:h-1 *:origin-[31%]',
},
},
defaultVariants: {
size: 'sm',
},
},
);
export function AnimatedMenuButtonToggleIcon({
className,
size = 'sm',
...props
}: React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof buttonIconVariants>) {
const { isOpen } = useAnimatedMenuContext();
return (
<div
className={cn(
buttonIconVariants({
size,
className,
}),
)}
{...props}
>
<motion.span animate={isOpen ? { rotate: 45 } : { rotate: 0 }} />
<motion.span animate={isOpen ? { rotate: -45 } : { rotate: 0 }} />
</div>
);
}
export function AnimatedMenuButtonLabel({
closeLabel = 'Menu',
openLabel = 'Close',
className,
...props
}: AnimatedMenuButtonLabelProps) {
const { isOpen } = useAnimatedMenuContext();
return (
<div
className={cn(
'text-sm font-medium overflow-hidden shrink-0 inline-grid grid-rows-1 grid-cols-1',
className,
)}
{...props}
>
<AnimatePresence>
<motion.span
key={closeLabel}
className="will-change-transform col-start-1 row-start-1"
initial={{ y: '0%' }}
animate={isOpen ? { y: '-100%' } : { y: '0%' }}
exit={{ y: '-100%' }}
>
{closeLabel}
</motion.span>
<motion.span
key={openLabel}
className="will-change-transform col-start-1 row-start-1"
initial={{ y: '100%' }}
animate={isOpen ? { y: '0%' } : { y: '100%' }}
exit={{ y: '100%' }}
>
{openLabel}
</motion.span>
</AnimatePresence>
</div>
);
}
export function AnimatedMenuButton({
className,
children,
...props
}: React.HTMLAttributes<HTMLButtonElement>) {
const { setIsOpen } = useAnimatedMenuContext();
const toggleMenu = () => setIsOpen((prevState) => !prevState);
return (
<button
className={cn(
'w-20 h-8',
'cursor-pointer text-popover-foreground appearance-none relative z-[999] bg-none flex gap-0.5 justify-center items-center',
'[&:hover>*]:scale-90 *:transition-transform *:duration-300',
className,
)}
onClick={toggleMenu}
{...props}
>
<MotionConfig
transition={{ duration: 0.35, ease: [0.445, 0.05, 0.55, 0.95] }}
>
{children}
</MotionConfig>
</button>
);
}
export function AnimatedMenuList({
variants = menuListVariants,
className,
children,
...props
}: React.ComponentPropsWithRef<'div'> & HTMLMotionProps<'div'>) {
const { isOpen, setIsOpen } = useAnimatedMenuContext();
const ref = useClickOutside<HTMLDivElement>(() => {
setIsOpen(false);
}, isOpen);
return (
<motion.div
ref={ref}
className={cn(
'absolute right-0 top-0 overflow-hidden bg-popover text-popover-foreground rounded-md z-[800] ',
className,
)}
variants={variants}
initial="close"
animate={isOpen ? 'open' : 'close'}
{...props}
>
<AnimatePresence>
{isOpen && (children as React.ReactNode)}
</AnimatePresence>
</motion.div>
);
}
export function AnimatedMenuItem({
order = 0,
variants = itemVariants,
...props
}: AnimatedMenuItemProps) {
return (
<motion.div
custom={order}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
{...props}
/>
);
}
export function CloseAnimatedMenu({
className,
...props
}: React.HTMLAttributes<HTMLButtonElement>) {
const { setIsOpen } = useAnimatedMenuContext();
const closeMenu = () => setIsOpen(false);
return (
<button
className={cn(
'appearance-none bg-none outline-none border-none',
className,
)}
onClick={closeMenu}
{...props}
/>
);
}