Dropdown Menu

PreviousNext

Animated Dropdown Menu Reveals a menu of options or actions when activated by a button.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/dropdown-menu.tsx
"use client"

import * as React from "react"
import { useEffect, useState } from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { motion, AnimatePresence, Variants } from "framer-motion"
import { cn } from "@/lib/utils"


const dropdownVariants: Variants = {
  hidden: {
    opacity: 0,
    scale: 0.5,
    rotateX: 40,
    y: 20,
  },
  visible: {
    opacity: 1,
    scale: 1,
    y: 0,
    rotateX: 0,
    transition: {
      type: "spring" as const,
      stiffness: 260,
      damping: 15,
    }
  },
  exit: {
    opacity: 0,
    scale: 0.8,
    rotateX: 10,
    y: 10,
    transition: {
      duration: 0.2
    }
  }
};

const itemVariants: Variants = {
  hidden: {
    opacity: 0,
    x: -20,
  },
  visible: (i: number) => ({
    opacity: 1,
    x: 0,
    transition: {
      delay: i * 0.05,
      duration: 0.2,
    }
  }),
  exit: {
    opacity: 0,
    x: -20,
    transition: {
      duration: 0.1,
    }
  }
};

const useMobile = () => {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const checkIfMobile = () => {
      setIsMobile(
        window.innerWidth < 768 ||
        /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
          navigator.userAgent
        )
      );
    };

    checkIfMobile();
    window.addEventListener("resize", checkIfMobile);

    return () => {
      window.removeEventListener("resize", checkIfMobile);
    };
  }, []);

  return isMobile;
};

function DropdownMenu({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}

function DropdownMenuTrigger({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
  return (
    <DropdownMenuPrimitive.Trigger
      data-slot="dropdown-menu-trigger"
      className={cn(
        "inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200",
        "hover:shadow-lg",
        className
      )}
      asChild
      {...props}
    >
      <motion.div
        whileHover={{ scale: 1.02 }}
        whileTap={{ scale: 0.98 }}
        transition={{ type: "spring", stiffness: 400, damping: 25 }}
        className="flex items-center gap-2 cursor-pointer"
      >
        {children}
      </motion.div>
    </DropdownMenuPrimitive.Trigger>
  )
}

interface DropdownMenuContentProps extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
  className?: string;
  sideOffset?: number;
  children?: React.ReactNode;
  maxHeight?: string | number;
}

function DropdownMenuContent({
  className,
  sideOffset = 4,
  maxHeight = "16rem",
  children,
  ...props
}: DropdownMenuContentProps) {
  return (
    <DropdownMenuPrimitive.Portal>
      <AnimatePresence>
        <DropdownMenuPrimitive.Content
          data-slot="dropdown-menu-content"
          sideOffset={sideOffset}
          className="z-50"
          asChild
          {...props}
        >
          <motion.div
            variants={dropdownVariants}
            initial="hidden"
            animate="visible"
            exit="exit"
            className={cn(
              "w-72 rounded-xl border shadow-xl overflow-hidden [perspective:800px] [transform-style:preserve-3d]",
              "bg-white/80 border-neutral-900/10 backdrop-blur-md",
              "dark:bg-neutral-900/80 dark:border-neutral-50/10",
              className
            )}
            style={{
              transformOrigin: "var(--radix-dropdown-menu-content-transform-origin)",
            }}
          >
            <div className={cn(
              "absolute inset-0 z-0",
              "bg-gradient-to-br from-indigo-500/10 to-purple-500/10",
              "dark:from-indigo-500/20 dark:to-purple-500/20"
            )} />

            <div className="absolute inset-0 backdrop-blur-sm z-10" />

            <div
              className="relative z-20 overflow-y-auto scrollbar-visible"
              style={{
                maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight,
                scrollbarWidth: "thin",
                scrollbarColor: "rgba(155, 155, 155, 0.5) transparent",
              }}
            >
              <style jsx global>{`
                .scrollbar-visible::-webkit-scrollbar {
                  width: 6px;
                  display: block;
                }
                .scrollbar-visible::-webkit-scrollbar-track {
                  background: transparent;
                }
                .scrollbar-visible::-webkit-scrollbar-thumb {
                  background-color: rgba(155, 155, 155, 0.5);
                  border-radius: 20px;
                }
                .scrollbar-visible::-webkit-scrollbar-thumb:hover {
                  background-color: rgba(155, 155, 155, 0.7);
                }
              `}</style>
              <div className="p-2">
                {React.Children.map(children, (child, index) => (
                  <motion.div
                    key={index}
                    custom={index}
                    variants={itemVariants}
                    initial="hidden"
                    animate="visible"
                    exit="exit"
                  >
                    {child}
                  </motion.div>
                ))}
              </div>
            </div>
          </motion.div>
        </DropdownMenuPrimitive.Content>
      </AnimatePresence>
    </DropdownMenuPrimitive.Portal>
  )
}

function DropdownMenuItem({
  className,
  inset,
  variant = "default",
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
  inset?: boolean
  variant?: "default" | "destructive"
  className?: string
}) {
  const [isHovered, setIsHovered] = useState(false);
  const isMobile = useMobile();

  return (
    <DropdownMenuPrimitive.Item
      data-slot="dropdown-menu-item"
      data-inset={inset}
      data-variant={variant}
      className={cn(
        "relative flex cursor-pointer items-center gap-3 rounded-lg p-2 text-sm outline-none select-none overflow-hidden",
        "transition-all duration-200 ease-out",
        "focus:outline-none focus:bg-transparent",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
        "data-[inset]:pl-8",
        "text-neutral-900 dark:text-neutral-50",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      onMouseEnter={() => !isMobile && setIsHovered(true)}
      onMouseLeave={() => !isMobile && setIsHovered(false)}
      asChild
      {...props}
    >
      <motion.div
        className="relative w-full"
        initial={{ opacity: 0, x: -20 }}
        animate={{
          opacity: 1,
          x: 0,
        }}
      >
        <AnimatePresence>
          {!isMobile && isHovered && (
            <motion.div
              layoutId="hoverBackground"
              initial={{ opacity: 0 }}
              animate={{
                opacity: 1,
                scale: 1.05,
                transition: {
                  type: "spring",
                  stiffness: 260,
                  damping: 15,
                },
              }}
              exit={{ opacity: 0 }}
              className={cn(
                "absolute inset-0 rounded-lg",
                variant === "destructive"
                  ? "bg-gradient-to-r from-red-500/10 to-red-600/10 dark:from-red-500/20 dark:to-red-600/20"
                  : "bg-gradient-to-r from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20"
              )}
            />
          )}
        </AnimatePresence>

        <div className={cn(
          "relative z-10 w-full flex items-center gap-3",
          variant === "destructive" && "text-red-600 dark:text-red-400"
        )}>
          {React.Children.map(children, (child, index) => {
            if (
              React.isValidElement(child) &&
              (
                child.type === 'svg' ||
                (
                  child.props &&
                  typeof child.props === 'object' &&
                  'className' in child.props &&
                  typeof child.props.className === 'string' &&
                  child.props.className.includes('lucide')
                )
              )
            ) {
              return (
                <motion.div
                  key={index}
                  animate={{
                    scale: !isMobile && isHovered ? 1.1 : 1,
                    rotate: !isMobile && isHovered ? 5 : 0,
                  }}
                  transition={{ type: "spring", stiffness: 500 }}
                >
                  {child}
                </motion.div>
              );
            }

            if (typeof child === 'string') {
              return (
                <motion.span
                  key={index}
                  animate={{
                    y: !isMobile && isHovered ? -1 : 0,
                    x: !isMobile && isHovered ? 1 : 0,
                  }}
                  transition={{ type: "spring", stiffness: 500 }}
                  className="font-medium flex-1"
                >
                  {child}
                </motion.span>
              );
            }

            return child;
          })}
        </div>
      </motion.div>
    </DropdownMenuPrimitive.Item>
  )
}

function DropdownMenuCheckboxItem({
  className,
  children,
  checked,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & { className?: string }) {
  const [isHovered, setIsHovered] = useState(false);
  const isMobile = useMobile();

  const handleSelect = (e: Event) => {
    e.preventDefault();
    setTimeout(() => {
      const event = new KeyboardEvent('keydown', { key: 'Escape' });
      document.dispatchEvent(event);
    }, 150);
    if (props.onSelect) props.onSelect(e);
  };

  return (
    <DropdownMenuPrimitive.CheckboxItem
      data-slot="dropdown-menu-checkbox-item"
      className={cn(
        "relative flex cursor-pointer items-center gap-3 rounded-lg py-2 pr-3 pl-8 text-sm outline-none select-none overflow-hidden",
        "transition-all duration-200 ease-out",
        "focus:outline-none focus:bg-transparent",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
        "text-neutral-900 dark:text-neutral-50",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
        className
      )}
      checked={checked}
      onMouseEnter={() => !isMobile && setIsHovered(true)}
      onMouseLeave={() => !isMobile && setIsHovered(false)}
      onSelect={handleSelect}
      asChild
      {...props}
    >
      <motion.div className="relative w-full">
        <AnimatePresence>
          {!isMobile && isHovered && (
            <motion.div
              layoutId="checkboxHoverBackground"
              initial={{ opacity: 0 }}
              animate={{
                opacity: 1,
                scale: 1.05,
                transition: {
                  type: "spring",
                  stiffness: 260,
                  damping: 15,
                },
              }}
              exit={{ opacity: 0 }}
              className="absolute inset-0 rounded-lg bg-gradient-to-r from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20"
            />
          )}
        </AnimatePresence>

        <span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center">
          <DropdownMenuPrimitive.ItemIndicator>
            <motion.div
              initial={{ scale: 0 }}
              animate={{ scale: 1 }}
              transition={{ type: "spring", stiffness: 400, damping: 25 }}
            >
              <CheckIcon className="size-4" />
            </motion.div>
          </DropdownMenuPrimitive.ItemIndicator>
        </span>

        <motion.div
          animate={{
            y: !isMobile && isHovered ? -1 : 0,
            x: !isMobile && isHovered ? 1 : 0,
          }}
          transition={{ type: "spring", stiffness: 500 }}
          className="relative z-10"
        >
          {children}
        </motion.div>
      </motion.div>
    </DropdownMenuPrimitive.CheckboxItem>
  )
}

function DropdownMenuRadioGroup({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
  return (
    <DropdownMenuPrimitive.RadioGroup
      data-slot="dropdown-menu-radio-group"
      {...props}
    />
  )
}

function DropdownMenuRadioItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & { className?: string }) {
  const [isHovered, setIsHovered] = useState(false);
  const isMobile = useMobile();

  const handleSelect = (e: Event) => {
    e.preventDefault();
    setTimeout(() => {
      const event = new KeyboardEvent('keydown', { key: 'Escape' });
      document.dispatchEvent(event);
    }, 150);
    if (props.onSelect) props.onSelect(e);
  };

  return (
    <DropdownMenuPrimitive.RadioItem
      data-slot="dropdown-menu-radio-item"
      className={cn(
        "relative flex cursor-pointer items-center gap-3 rounded-lg py-2 pr-3 pl-8 text-sm outline-none select-none overflow-hidden",
        "transition-all duration-200 ease-out",
        "focus:outline-none focus:bg-transparent",
        "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
        "text-neutral-900 dark:text-neutral-50",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-2.5",
        className
      )}
      onMouseEnter={() => !isMobile && setIsHovered(true)}
      onMouseLeave={() => !isMobile && setIsHovered(false)}
      onSelect={handleSelect}
      asChild
      {...props}
    >
      <motion.div className="relative w-full">
        <AnimatePresence>
          {!isMobile && isHovered && (
            <motion.div
              layoutId="radioHoverBackground"
              initial={{ opacity: 0 }}
              animate={{
                opacity: 1,
                scale: 1.05,
                transition: {
                  type: "spring",
                  stiffness: 260,
                  damping: 15,
                },
              }}
              exit={{ opacity: 0 }}
              className="absolute inset-0 rounded-lg bg-gradient-to-r from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20"
            />
          )}
        </AnimatePresence>

        <span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center">
          <DropdownMenuPrimitive.ItemIndicator>
            <motion.div
              initial={{ scale: 0 }}
              animate={{ scale: 1 }}
              transition={{ type: "spring", stiffness: 400, damping: 25 }}
            >
              <CircleIcon className="size-2 fill-current" />
            </motion.div>
          </DropdownMenuPrimitive.ItemIndicator>
        </span>

        <motion.div
          animate={{
            y: !isMobile && isHovered ? -1 : 0,
            x: !isMobile && isHovered ? 1 : 0,
          }}
          transition={{ type: "spring", stiffness: 500 }}
          className="relative z-10"
        >
          {children}
        </motion.div>
      </motion.div>
    </DropdownMenuPrimitive.RadioItem>
  )
}

function DropdownMenuLabel({
  className,
  inset,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
  inset?: boolean
  className?: string
}) {
  return (
    <div className="p-2 sticky top-0 z-20">
      <DropdownMenuPrimitive.Label
        data-slot="dropdown-menu-label"
        data-inset={inset}
        className={cn(
          "px-3 py-2 text-sm font-bold text-neutral-900 dark:text-neutral-50",
          "data-[inset]:pl-8",
          className
        )}
        {...props}
      />
    </div>
  )
}

function DropdownMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator> & { className?: string }) {
  return (
    <motion.div
      initial={{ scaleX: 0, opacity: 0 }}
      animate={{
        scaleX: 1,
        opacity: 1,
        transition: {
          delay: 0.1,
          type: "spring",
          stiffness: 400,
          damping: 25
        }
      }}
      className="flex justify-center py-1"
    >
      <DropdownMenuPrimitive.Separator
        data-slot="dropdown-menu-separator"
        className={cn(
          "my-1 h-px w-full bg-neutral-900/10 dark:bg-white/10",
          className
        )}
        {...props}
      />
    </motion.div>
  )
}

function DropdownMenuShortcut({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="dropdown-menu-shortcut"
      className={cn(
        "ml-auto text-xs tracking-widest text-neutral-500 dark:text-neutral-400",
        className
      )}
      {...props}
    />
  )
}

function DropdownMenuSub({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}

function DropdownMenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
  inset?: boolean
  className?: string
}) {
  const [isHovered, setIsHovered] = useState(false);
  const isMobile = useMobile();

  return (
    <DropdownMenuPrimitive.SubTrigger
      data-slot="dropdown-menu-sub-trigger"
      data-inset={inset}
      className={cn(
        "relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm outline-none select-none overflow-hidden",
        "transition-all duration-200 ease-out",
        "focus:outline-none",
        "text-neutral-900 dark:text-neutral-50",
        "data-[inset]:pl-8",
        "data-[state=open]:bg-gradient-to-r data-[state=open]:from-indigo-500/10 data-[state=open]:to-purple-500/10",
        "dark:data-[state=open]:from-indigo-500/20 dark:data-[state=open]:to-purple-500/20",
        !isMobile && isHovered && "bg-gradient-to-r from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20",
        className
      )}
      onMouseEnter={() => !isMobile && setIsHovered(true)}
      onMouseLeave={() => !isMobile && setIsHovered(false)}
      {...props}
    >
      <div className="w-full flex items-center gap-3">
        <motion.div
          animate={{
            y: !isMobile && isHovered ? -1 : 0,
            x: !isMobile && isHovered ? 1 : 0,
          }}
          transition={{ type: "spring", stiffness: 500 }}
          className="flex-1"
        >
          {children}
        </motion.div>
        <motion.div
          animate={{
            rotate: !isMobile && isHovered ? 90 : 0,
            scale: !isMobile && isHovered ? 1.1 : 1,
          }}
          transition={{ type: "spring", stiffness: 500 }}
        >
          <ChevronRightIcon className="ml-auto size-4" />
        </motion.div>
      </div>
    </DropdownMenuPrimitive.SubTrigger>
  )
}

interface DropdownMenuSubContentProps extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> {
  className?: string;
  children?: React.ReactNode;
}

function DropdownMenuSubContent({
  className,
  children,
  ...props
}: DropdownMenuSubContentProps) {
  return (
    <DropdownMenuPrimitive.SubContent
      data-slot="dropdown-menu-sub-content"
      className="z-50"
      asChild
      {...props}
    >
      <motion.div
        variants={dropdownVariants}
        initial="hidden"
        animate="visible"
        exit="exit"
        className={cn(
          "min-w-[8rem] rounded-xl border shadow-xl overflow-hidden [perspective:800px] [transform-style:preserve-3d]",
          "bg-white/80 border-neutral-900/10 backdrop-blur-md",
          "dark:bg-neutral-900/80 dark:border-neutral-50/10",
          className
        )}
        style={{
          transformOrigin: "var(--radix-dropdown-menu-content-transform-origin)",
        }}
      >
        <div className={cn(
          "absolute inset-0 z-0",
          "bg-gradient-to-br from-indigo-500/10 to-purple-500/10",
          "dark:from-indigo-500/20 dark:to-purple-500/20"
        )} />

        <div className="absolute inset-0 backdrop-blur-sm z-10" />

        <div className="p-1 w-full relative z-20">
          {React.Children.map(children, (child, index) => (
            <motion.div
              key={index}
              custom={index}
              variants={itemVariants}
              initial="hidden"
              animate="visible"
              exit="exit"
            >
              {child}
            </motion.div>
          ))}
        </div>
      </motion.div>
    </DropdownMenuPrimitive.SubContent>
  )
}

function DropdownMenuGroup({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
  return (
    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
  )
}

function DropdownMenuPortal({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
  return (
    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
  )
}

export {
  DropdownMenu,
  DropdownMenuPortal,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuItem,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubTrigger,
  DropdownMenuSubContent,
}

Installation

npx shadcn@latest add @scrollxui/dropdown-menu

Usage

import { DropdownMenu } from "@/components/dropdown-menu"
<DropdownMenu />