Random UI

Floating Menu

Beautiful, animated and totally accessible floating menu

GSAP
GSAP
Tailwind CSS
Tailwind CSS

Demo

Floating Menu

Beautiful, animated and totally accessible floating menu

Features

AccessibleThe menu is fully accessible, with ARIA attributes, tab navigation and keyboard navigation.
ThemeThe menu is fully themeable, with light and dark mode already managed.
ResponsiveThe menu is fully responsive.
Ehm...I didn't know what to write here but I needed to fill the fourth column.

Pricing

FreeThe menu is free to use.
Free PlusThe menu is pro to use.
Free ProThe menu is enterprise to use.

This menu uses only tailwind classes, no custom Css. It's fully accessible, responsive and customisable.

Installation

Install the following dependencies:

npm install gsap @gsap/react lucide-react
pnpm add gsap @gsap/react lucide-react
yarn add gsap @gsap/react lucide-react
bun add gsap @gsap/react lucide-react

This menu uses the Magnet component, so make sure to copy it.

Copy and paste the following code into your project.

floating-menu.tsx
"use client";

import { XIcon, MenuIcon } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
import Magnet from "@/components/magnet";


const MENU_OPTIONS: OptionItem[] = [
  {
    id: "o1",
    label: "Option 1",
    longLabel: "Option 1",
  },
  {
    id: "o2",
    label: "Option 2",
    longLabel: "Option 2",
  },
  {
    id: "o3",
    label: "Option 3",
    longLabel: "Option 3",
  },
];

export type OptionItem = {
  id: string;
  label: string;
  longLabel?: string;
};

const FloatingMenu: React.FC = () => {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [currentOption, setCurrentOption] = useState<OptionItem | null>(null);

  const isCurrentAnimating = useRef<boolean>(false);
  const optionsRefs = useRef<HTMLButtonElement[]>([]);

  /**
   * Reusable function to fade out the menu panel and set the openLink to null
   * @param onComplete
   * @param duration
   */
  const fadeOutOptionsPanel = (onComplete: () => void) => {
    const fadeOutTl = gsap.timeline();
    fadeOutTl
      .to("#menu-options", {
        opacity: 0,
        duration: 0.2,
        y: 10,
        ease: "expo.out",
        filter: "blur(10px)",
      })
      .to(
        "#menu-options",
        {
          duration: 0,
          onComplete: () => {
            fadeOutTl.kill();
            onComplete();
          },
        },
        "+=0.5"
      );
  };

  // To focus the first menu option when the menu is opened
  useEffect(() => {
    if (isMenuOpen && optionsRefs.current[0]) {
      optionsRefs.current[0].focus();
    }
  }, [isMenuOpen]);

  // To navigate through the menu options and close the menu via keyboard
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      setIsMenuOpen(false);
      if (currentOption)
        fadeOutOptionsPanel(() => {
          setCurrentOption(null);
        });
      if (
        document.activeElement &&
        document.activeElement instanceof HTMLElement
      ) {
        document.activeElement.blur();
      }
    }

    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      const currentIndex = optionsRefs.current.findIndex(
        (ref) => ref === document.activeElement
      );
      if (currentIndex === -1) return;

      const nextIndex =
        e.key === "ArrowRight"
          ? Math.min(currentIndex + 1, optionsRefs.current.length - 1)
          : Math.max(currentIndex - 1, 0);

      if (nextIndex !== currentIndex) {
        optionsRefs.current[nextIndex]?.focus();
      }
    }
  };

  // Core GSAP hook listening for Menu open/close
  useGSAP(() => {
    const tl = gsap.timeline({
      onComplete: () => {
        gsap.set("#core-panel", {
          pointerEvents: isMenuOpen ? "auto" : "none",
        });
        gsap.set(".menu_option", { pointerEvents: isMenuOpen ? "auto" : "none" });
      },
    });

    tl.to(".ham-icon", {
      opacity: isMenuOpen ? 0 : 1,
      scale: isMenuOpen ? 0 : 1,
      duration: isMenuOpen ? 0.2 : 0.3,
      rotation: isMenuOpen ? 180 : 0,
      ease: isMenuOpen ? "expo.out" : "back.out(1.7)",
    });
    tl.to(
      ".x-icon",
      {
        opacity: isMenuOpen ? 1 : 0,
        scale: isMenuOpen ? 1 : 0.8,
        rotation: isMenuOpen ? 180 : 0,
        duration: 0.2,
        ease: isMenuOpen ? "back.out(1.7)" : "expo.in",
      },
      "<"
    );
    tl.to(
      "#core-panel",
      {
        opacity: isMenuOpen ? 1 : 0,
        filter: isMenuOpen ? "blur(0px)" : "blur(10px)",
        y: isMenuOpen ? 0 : 10,
        duration: 0.5,
        ease: isMenuOpen ? "expo.out" : "expo.in",
      },
      "<"
    );

    tl.to(
      ".menu_option",
      {
        opacity: isMenuOpen ? 1 : 0,
        filter: isMenuOpen ? "blur(0px)" : "blur(10px)",
        x: isMenuOpen ? 0 : 40,
        duration: 0.3,
        stagger: 0.1,
        ease: isMenuOpen ? "expo.out" : "expo.in",
      },
      "<+=0.1"
    );
  }, [isMenuOpen]);

  const openOption = (option: OptionItem) => {
    if (currentOption && currentOption.id === option.id) return;
    if (isCurrentAnimating.current) return;

    const fadeInAnimation = () => {
      setCurrentOption(option);
      const fadeAnimation = gsap.to("#menu-options", {
        opacity: 1,
        duration: 0.2,
        y: 0,
        ease: "expo.out",
        filter: "blur(0px)",
        onComplete: () => {
          fadeAnimation.kill();
          isCurrentAnimating.current = false;
        },
      });
    };

    isCurrentAnimating.current = true;

    if (currentOption) fadeOutOptionsPanel(fadeInAnimation);
    else fadeInAnimation();
  };

  const toggleMenu = () => {
    setIsMenuOpen(!isMenuOpen);

    if (currentOption)
      fadeOutOptionsPanel(() => {
        setCurrentOption(null);
      });
  };

  const closeOptionsPanel = () => {
    const tl = gsap.timeline();

    tl.to("#menu-options", {
      opacity: 0,
      duration: 0.2,
      y: 10,
      ease: "expo.out",
      filter: "blur(10px)",
    })
      // To wait for the menu options to fade out
      .to(
        "#menu-options",
        {
          duration: 0,
          onComplete: () => {
            setCurrentOption(null);
            tl.kill();
          },
        },
        "+=0.5"
      );
  };

  return (
    <nav
      role="navigation"
      aria-label="Main Menu"
      className={cn(
        // Layout & positioning
        "absolute top-8 right-8 z-[999]",
        // Flexbox
        "flex flex-col items-end gap-2 w-84",
        // Interactive states
        !isMenuOpen && "pointer-events-none",
      )}
      onKeyDown={handleKeyDown}
    >
      <div className="flex items-center justify-end gap-2">
        <nav
          className={cn(
            // Flexbox
            "flex items-center gap-2",
            // Interactive states
            !isMenuOpen && "pointer-events-none"
          )}
          role="tablist"
          aria-label="Menu options"
        >
          {MENU_OPTIONS.map((link, index) => (
            <button
              role="tab"
              ref={(el) => {
                optionsRefs.current[index] = el!;
              }}
              key={link.id}
              onClick={() => openOption(link)}
              aria-expanded={currentOption?.id === link.id}
              aria-controls={`option-${link.label.toLowerCase().replace(/ /g, "-")}`}
              aria-haspopup="true"
              aria-selected={currentOption?.id === link.id}
              className={cn(
                // Base styles
                "menu_option text-sm dark:text-white text-[#2b2a2a]",
                // Layout & sizing
                "w-fit rounded-xl py-1 px-3",
                // Background & borders
                "dark:bg-white/5 border dark:border-white/5 backdrop-blur-sm bg-black/20 border-black/20",
                // Interactive states
                "cursor-pointer transition-all duration-500",
                "hover:border-transparent hover:dark:bg-white/10",
                "focus:outline-none focus:border-black focus:dark:border-white/50",
                // Animation states
                "opacity-0 blur-[10px] translate-x-10 pointer-events-auto",
                // Active state
                currentOption?.id === link.id && "dark:bg-white/10 bg-black/30"
              )}
            >
              {link.label}
            </button>
          ))}
        </nav>
        <Magnet isActive={!isMenuOpen} className="flex">
          <button
            onClick={toggleMenu}
            aria-expanded={isMenuOpen}
            aria-controls="core-panel menu-options"
            aria-label={isMenuOpen ? "Close menu" : "Open menu"}
            className={cn(
              // Layout
              "button-menu size-8 md:size-10 text-sm cursor-pointer relative z-30 aspect-square rounded-full backdrop-blur-sm pointer-events-auto",
              // Colors
              "bg-black/20 dark:bg-white/5 border border-black/20 dark:border-white/5 text-[#2b2a2a] dark:text-white",
              // Interactive states
              "transition-all duration-500",
              "hover:border-transparent hover:bg-black/30 hover:dark:bg-white/10",
              "focus:outline-none focus:border-black focus:dark:border-white/50",
            )}
          >
            <XIcon
              className="x-icon absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 size-4 md:size-6"
              aria-hidden="true"
            />
            <MenuIcon
              className="ham-icon absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-100 size-4 md:size-6"
              aria-hidden="true"
            />
          </button>
        </Magnet>
      </div>

      <div
        id="core-panel"
        role="region"
        aria-label="Core panel"
        className={cn(
          // Layout
          "w-full rounded-xl overflow-hidden",
          "backdrop-blur-sm opacity-0 blur-[10px] translate-y-5",
          // Colors
          "bg-black/20 dark:bg-white/5 border border-black/20 dark:border-white/5 transition-colors duration-500",
          // Interactive states
          "hover:bg-black/30 hover:dark:bg-white/10",
          !isMenuOpen && "pointer-events-none"
        )}
        aria-hidden={!isMenuOpen}
      >
        <div
          className={cn(
            // Base styles
            "text-sm text-[#2b2a2a] dark:text-white",
            // Layout
            "flex flex-col gap-2 p-3",
            // Interactive states
            "transition-all duration-500 hover:border-transparent"
          )}
        >
            Core panel
        </div>
      </div>
      <div
        id="menu-options"
        role="region"
        aria-label={`Option ${currentOption?.label}`}
        aria-live="polite"
        className={cn(
          // Base styles
          "text-sm",
          // Layout
          "w-full min-h-10 flex flex-col gap-4 rounded-xl p-3",
          "opacity-0 translate-y-2.5 blur-[10px]",
          // Colors
          "backdrop-blur-sm bg-black/20 dark:bg-white/5 border border-black/20 dark:border-white/5",
          // Interactive states
          "transition-all duration-500 hover:border-transparent hover:dark:bg-white/10 hover:bg-black/30",
          (!currentOption || !isMenuOpen) && "invisible pointer-events-none"
        )}
      >
        <div className="flex items-center justify-between w-full dark:text-[#949494]">
          <h2 className="text-sm !m-0">{currentOption?.longLabel ?? currentOption?.label}</h2>
          <button
            onClick={closeOptionsPanel}
            aria-label="Close options panel"
            className={cn(
              "text-sm cursor-pointer transition-all duration-200",
              "hover:text-black hover:dark:text-white focus:outline-none focus:scale-110 focus:text-black focus:dark:text-white"
            )}
          >
            <XIcon className="size-4" aria-hidden="true" />
          </button>
        </div>
        <OptionsPanelContent option={currentOption} />
      </div>
    </nav>
  );
};

const OptionsPanelContent = ({ option }: { option: OptionItem | null }) => {
  if (!option) return null;

  if (option.id === "about")
    return (
      <div
        className="w-full text-[#2b2a2a] dark:text-white"
        id={`option-${option.label.toLowerCase().replace(/ /g, "-")}`}
        aria-labelledby={`option-${option.label.toLowerCase().replace(/ /g, "-")}`}
      >
        {option.label}
      </div>
    );

  if (option.id === "contacts")
    return (
      <div
        className="w-full text-[#2b2a2a] dark:text-white"
        id={`option-${option.label.toLowerCase().replace(/ /g, "-")}`}
        aria-labelledby={`option-${option.label.toLowerCase().replace(/ /g, "-")}`}
      >
        {option.label}
      </div>
    );

  if (option.id === "who")
    return (
      <div
        className="w-full text-[#2b2a2a] dark:text-white"
        id={`option-${option.label.toLowerCase().replace(/ /g, "-")}`}
        aria-labelledby={`option-${option.label.toLowerCase().replace(/ /g, "-")}`}
      >
        {option.label}
      </div>
    );
};

export default FloatingMenu;

Usage

import FloatingMenu from "@/components/floating-menu";
layout.tsx
return(
    <body>
        <FloatingMenu />
        {children}
    </body>
)