shadimage
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 · medium
animationmotion

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}
    />
  );
}

More from Systaliko UI

Browse all components in this library →