Animated Profile Menu

PreviousNext

Floating profile avatar that reveals quick action cards with smooth motion

Docs
uitripledcomponent

Preview

Loading preview…
components/navigation/animated-profile-menu.tsx
"use client";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import type { LucideIcon } from "lucide-react";
import { ChevronRight, LogOut, Settings, TrendingUp, User } from "lucide-react";
import { useCallback, useId, useState } from "react";

interface MenuItem {
  id: number;
  icon: LucideIcon;
  title: string;
  description: string;
}

const PROFILE_MENU_ITEMS: MenuItem[] = [
  {
    id: 1,
    icon: User,
    title: "Profile",
    description: "View your details",
  },
  {
    id: 2,
    icon: TrendingUp,
    title: "Stats",
    description: "Track your activity",
  },
  {
    id: 3,
    icon: Settings,
    title: "Settings",
    description: "Manage preferences",
  },
  {
    id: 4,
    icon: LogOut,
    title: "Logout",
    description: "Sign out securely",
  },
];

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1,
      delayChildren: 0.12,
    },
  },
};

const itemVariants: Variants = {
  hidden: {
    opacity: 0,
    x: -16,
    scale: 0.94,
  },
  visible: {
    opacity: 1,
    x: 0,
    scale: 1,
    transition: { duration: 0.3, ease: "easeOut" },
  },
  exit: {
    opacity: 0,
    x: -16,
    scale: 0.92,
    transition: { duration: 0.18, ease: "easeInOut" },
  },
};

export function AnimatedProfileMenu() {
  const [isOpen, setIsOpen] = useState(false);
  const menuId = useId();

  const toggleMenu = useCallback(() => {
    setIsOpen((previous) => !previous);
  }, []);

  const closeMenu = useCallback(() => {
    setIsOpen(false);
  }, []);

  return (
    <nav
      className="fixed preview:absolute not-preview:bottom-6 not-preview:left-6 z-50"
      role="navigation"
      aria-label="Profile quick actions"
    >
      <AnimatePresence>
        {isOpen && (
          <motion.div
            key="profile-menu"
            variants={containerVariants}
            initial="hidden"
            animate="visible"
            exit="hidden"
            className="absolute bottom-24 left-0 space-y-3"
            role="presentation"
          >
            <motion.ul
              id={menuId}
              role="list"
              aria-label="Profile actions"
              className="flex flex-col gap-3"
            >
              {PROFILE_MENU_ITEMS.map((item) => {
                const Icon = item.icon;

                return (
                  <motion.li
                    key={item.id}
                    variants={itemVariants}
                    whileHover={{ x: 6 }}
                    transition={{ duration: 0.2 }}
                    role="listitem"
                  >
                    <motion.button
                      type="button"
                      onClick={closeMenu}
                      whileHover={{ scale: 1.02 }}
                      whileTap={{ scale: 0.98 }}
                      transition={{ duration: 0.18 }}
                      className="group relative block w-full overflow-hidden rounded-2xl border border-border/40 bg-background/60 text-left shadow-[0_24px_80px_-32px_rgba(15,23,42,0.45)] backdrop-blur px-4 py-4 transition-all hover:border-border/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                      aria-label={`${item.title}: ${item.description}`}
                    >
                      <div className="absolute inset-0 bg-gradient-to-br from-foreground/[0.04] via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 -z-10" />

                      <div className="relative flex min-w-[240px] items-center gap-4">
                        <span className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/30 bg-primary/10 text-primary transition-colors duration-300 group-hover:bg-primary/20 group-hover:text-primary/90">
                          <Icon className="h-5 w-5" aria-hidden="true" />
                        </span>
                        <div className="flex-1">
                          <h3 className="text-sm font-semibold text-foreground">
                            {item.title}
                          </h3>
                          <p className="text-xs text-foreground/60">
                            {item.description}
                          </p>
                        </div>
                        <ChevronRight
                          className="h-4 w-4 text-foreground/40 transition-transform duration-300 group-hover:translate-x-1"
                          aria-hidden="true"
                        />
                      </div>
                    </motion.button>
                  </motion.li>
                );
              })}
            </motion.ul>
          </motion.div>
        )}
      </AnimatePresence>

      <motion.button
        type="button"
        onClick={toggleMenu}
        whileHover={{ scale: 1.05 }}
        whileTap={{ scale: 0.95 }}
        animate={{ rotate: isOpen ? 90 : 0 }}
        transition={{ duration: 0.28, ease: "easeInOut" }}
        className="group relative flex h-16 w-16 items-center justify-center"
        aria-label={isOpen ? "Close profile menu" : "Open profile menu"}
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls={menuId}
      >
        <span
          aria-hidden="true"
          className="absolute inset-0 rounded-full bg-primary/20 blur-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
        />

        <Avatar className="relative h-16 w-16 cursor-pointer border-4 border-background shadow-xl ring-2 ring-border/40 transition-all duration-300 group-hover:ring-4 group-hover:ring-primary/40">
          <AvatarImage
            src="https://iimydr2b8o.ufs.sh/f/Zqn6AViLMoTte8EAuCfSJKE21d4F6TYyNAxBGU3aRflMr5Sn"
            alt="User profile picture"
            className="object-cover"
          />
          <AvatarFallback className="bg-gradient-to-br from-primary/80 to-primary text-primary-foreground text-xl font-semibold">
            JD
          </AvatarFallback>
        </Avatar>
      </motion.button>

      <span className="sr-only" role="status" aria-live="polite">
        {isOpen ? "Profile menu expanded" : "Profile menu collapsed"}
      </span>
    </nav>
  );
}

Installation

npx shadcn@latest add @uitripled/animated-profile-menu

Usage

import { AnimatedProfileMenu } from "@/components/animated-profile-menu"
<AnimatedProfileMenu />