An animated, fully accessible floating menu for React. Customizable and built with Tailwind CSS.
S1 is the first 15-cm chip, with a lot of AI transistors (??? maybe?) for more speed and power efficiency. Bunch of Neural Engine cores, up to 80% faster, and AI-ready for the AI-world in the AI-era.
Up to 50% faster CPU and GPU than a Samsung galaxy S3 NEO
A lot of transistors
some Neural Engine
Like 70% faster than a fiat multipla
First 17-centimeter chip
Up to 80% faster than something faster
"use client";
import { Mail, Phone } from "lucide-react";
import { cn } from "@/lib/utils";
import { FloatingMenu } from "@/components/floating-menu";
const MenuOptions = [
{
content: (
<span className="h-10">
This is the first panel with a simple text to show how the menu works.
</span>
),
id: "o1",
label: "About",
},
{
content: (
<div className="w-full space-y-4">
<div className="flex items-center gap-2">
<svg
aria-hidden={true}
className="size-4 fill-black dark:fill-white"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Facebook</title>
<path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" />
</svg>
<svg
aria-hidden={true}
className="size-4 fill-black dark:fill-white"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>X</title>
<path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z" />
</svg>
<svg
aria-hidden={true}
className="size-4 fill-black dark:fill-white"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Instagram</title>
<path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" />
</svg>
<svg
aria-hidden={true}
className="size-4 fill-black dark:fill-white"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>YouTube</title>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
</div>
<div className="flex gap-8">
<div className="flex items-center gap-2 text-[#545454] dark:text-[#949494]">
<Phone className="size-4" />
<span className="text-sm">+127 0 0 1</span>
</div>
<div className="flex items-center gap-2 text-[#545454] dark:text-[#949494]">
<Mail className="size-4" />
<span className="text-sm">scam@clickme.link</span>
</div>
</div>
<p>
For any question or request, don't contact me on any of my
Socials.
</p>
</div>
),
id: "o2",
label: "Contacts",
},
{
content: (
<pre className="wrap-break-words w-full whitespace-pre-wrap text-sm leading-relaxed">
{`Who let the dogs out?
Who, who, who, who, who?
Who let the dogs out?
Who, who, who, who, who?
Who let the dogs out?
Who, who, who, who, who?
Who let the dogs out?
`}
</pre>
),
id: "o3",
label: "Who?",
},
];
const FloatingMenuDemo: React.FC = () => {
const defaultOpen = null;
const toggleMainPanel = true;
return (
<div className="dark relative flex min-h-190 flex-col items-stretch justify-start overflow-hidden bg-black p-0 selection:bg-[#6ec8ff]/35 selection:text-white md:min-h-205">
<FloatingMenu.Root className="top-8 right-8 md:top-12 md:right-12">
<FloatingMenu.Trigger />
<FloatingMenu.Nav>
{MenuOptions.map((opt) => (
<FloatingMenu.NavItem key={opt.id}>
{opt.label}
</FloatingMenu.NavItem>
))}
</FloatingMenu.Nav>
{toggleMainPanel ? (
<FloatingMenu.MainPanel>
<span className="text-lg">Let's get in touch!</span>
<span className="text-[#545454] dark:text-[#949494]">
Keep updated with the latest (fake)news and (unstable)releases
from our team (just me).
</span>
<div className="mt-2 flex items-center gap-2">
<input
className="w-full border-white/5 border-b py-1 focus:border-white/20 focus:outline-none"
placeholder="real-email@forsure.lol"
type="text"
/>
<button
className="whitespace-nowrap rounded-lg border border-white/5 bg-white/5 p-2 text-[#2b2a2a] text-xs backdrop-blur-sm hover:bg-white/10 dark:text-white"
type="button"
>
Unscribe
</button>
</div>
</FloatingMenu.MainPanel>
) : null}
<FloatingMenu.SecondaryPanel>
{MenuOptions.map((opt) => (
<FloatingMenu.SecondaryPanelItem
defaultOpen={defaultOpen === opt.id}
key={opt.id}
>
{opt.content}
</FloatingMenu.SecondaryPanelItem>
))}
</FloatingMenu.SecondaryPanel>
</FloatingMenu.Root>
<ChipLanding />
</div>
);
};
function ChipLanding() {
return (
<article className="relative z-10 flex w-full flex-col items-center px-5 pt-12 pb-10 text-center md:px-10 md:pt-14 md:pb-12">
<h1 className="mt-3 max-w-[16ch] text-balance font-semibold font-serif text-[1.85rem] text-white leading-[1.08] tracking-hero md:mt-4 md:text-[2.75rem] lg:text-[3.15rem]">
Our chips are generations ahead of Super NESS
</h1>
<p className="mt-4 max-w-[54ch] text-pretty font-sans text-[#d4d4d8] text-[13px] leading-relaxed md:mt-5 md:text-[15px]">
S1 is the first 15-cm chip, with a lot of AI transistors (??? maybe?)
for more speed and power efficiency. Bunch of Neural Engine cores, up to
80% faster, and AI-ready for the AI-world in the AI-era.
</p>
<ChipDie className="mt-8 w-full max-w-205 md:mt-10" />
<button
className="mt-5 inline-flex items-center rounded-full border border-white/80 px-4 py-1.5 font-sans text-[13px] text-white tracking-[-0.01em] transition-[background-color,border-color,transform] duration-300 ease-out hover:border-white hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-[#6ec8ff] focus-visible:outline-offset-4 active:scale-[0.98] md:mt-6"
type="button"
>
How we did it???? eh? how?
</button>
</article>
);
}
const DIE_CELLS = [
{
accent: "50%",
area: "cpu",
body: "faster CPU and GPU than a Samsung galaxy S3 NEO",
prefix: "Up to",
},
{
accent: "A lot of",
area: "transistors",
body: "transistors",
prefix: null,
},
{
accent: "some",
area: "neural",
body: "Neural Engine",
prefix: null,
},
{
accent: "70%",
area: "ml",
body: "faster than a fiat multipla",
prefix: "Like",
},
{
accent: "17-centimeter",
area: "process",
body: "chip",
prefix: "First",
},
{
accent: "80%",
area: "isp",
body: "faster than something faster",
prefix: "Up to",
},
] as const;
function ChipDie({ className }: { className?: string }) {
return (
<div
className={cn("relative isolate w-full", className)}
id="a14-architecture"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-[8%] rounded-[4px] bg-[radial-gradient(ellipse_at_58%_38%,rgb(110_200_255/0.16),transparent_58%)] blur-2xl"
/>
<ChipTraces />
<div
className="relative grid min-h-70 w-full md:min-h-85"
style={{
gridTemplateAreas: `
"void-a void-b process process void-c"
"void-d cpu cpu void-e void-e"
"transistors transistors neural void-f void-f"
"ml ml isp isp void-g"
`,
gridTemplateColumns: "0.7fr 1.05fr 1.15fr 0.95fr 0.85fr",
gridTemplateRows: "0.72fr 1.15fr 1fr 1.05fr",
}}
>
{DIE_CELLS.map((cell) => (
<DieStat key={cell.area} {...cell} />
))}
</div>
</div>
);
}
function DieStat({ accent, area, body, prefix }: (typeof DIE_CELLS)[number]) {
return (
<p
className={cn(
"m-0 flex flex-col justify-center px-3 py-2 text-left font-sans text-[#ececec] text-[11px] leading-snug md:px-4 md:py-3 md:text-[13px]",
area === "cpu" && "md:text-[14px]",
area === "process" && "justify-end pb-3"
)}
style={{ gridArea: area }}
>
<span>
{prefix ? `${prefix} ` : null}
<span className="font-semibold font-serif text-[#6ec8ff] text-[1.35em] tracking-[-0.03em] md:text-[1.55em]">
{accent}
</span>{" "}
{body}
</span>
</p>
);
}
function ChipTraces() {
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible motion-safe:animate-[chip-trace_8s_ease-in-out_infinite]"
fill="none"
preserveAspectRatio="none"
viewBox="0 0 1000 480"
>
<defs>
<filter
colorInterpolationFilters="sRGB"
height={"124%"}
id="floating-menu-chip-glow"
width={"116%"}
x={"-8%"}
y={"-12%"}
>
<feGaussianBlur result="blur" stdDeviation="1.6" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter="url(#floating-menu-chip-glow)" strokeLinecap="square">
<path
d="M12 12 H988 V468 H12 Z"
opacity="0.28"
stroke="#6ec8ff"
strokeWidth="1"
/>
<path
d="M12 86 H988 M12 232 H988 M12 348 H988"
opacity="0.22"
stroke="#6ec8ff"
strokeWidth="1"
/>
<path
d="M148 12 V348 M418 86 V468 M698 12 V232 M818 232 V468"
opacity="0.2"
stroke="#6ec8ff"
strokeWidth="1"
/>
<path
d="M148 86 H698 V232 H148 Z"
opacity="0.95"
stroke="#8ad6ff"
strokeWidth="1.35"
/>
<path
d="M418 12 H818 V86"
opacity="0.55"
stroke="#6ec8ff"
strokeWidth="1.1"
/>
<path
d="M12 232 H418 V348 H12 Z"
opacity="0.72"
stroke="#7eceff"
strokeWidth="1.15"
/>
<path
d="M418 232 H698 V348"
opacity="0.62"
stroke="#7eceff"
strokeWidth="1.1"
/>
<path
d="M12 348 H418 V468 H12 Z"
opacity="0.78"
stroke="#8ad6ff"
strokeWidth="1.2"
/>
<path
d="M418 348 H988"
opacity="0.38"
stroke="#6ec8ff"
strokeWidth="1"
/>
<path
d="M148 12 V86 M818 12 V86 M698 86 V12"
opacity="0.42"
stroke="#6ec8ff"
strokeWidth="1"
/>
<path
d="M168 108 H320 M168 128 H248 M178 148 H300"
opacity="0.18"
stroke="#6ec8ff"
strokeWidth="0.8"
/>
<path
d="M740 260 H900 M760 280 H860 M790 300 H940"
opacity="0.14"
stroke="#6ec8ff"
strokeWidth="0.8"
/>
</g>
</svg>
);
}npm install motionbun add motiondeno add motionpnpm add motionyarn add motionCopy and paste the following code into your project.
"use client";
import {
AnimatePresence,
HTMLMotionProps,
type Variants,
backIn,
backOut,
motion,
} from "motion/react";
import {
Children,
cloneElement,
createContext,
type Dispatch,
isValidElement,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
/* ------------------------- Easings + variants ----------------------------- */
const expoIn = (t: number) => (t === 0 ? 0 : 2 ** (10 * (t - 1)));
const expoOut = (t: number) => (t === 1 ? 1 : 1 - 2 ** (-10 * t));
const navItemVariants: Variants = {
initial: { opacity: 0, filter: "blur(10px)", translateX: 40 },
open: ({ index }: { index: number }) => ({
opacity: 1,
filter: "blur(0px)",
translateX: 0,
transition: { duration: 0.6, ease: expoOut, delay: 0.3 + index * 0.1 },
}),
closed: ({
index,
secondaryPanelOpen,
}: { index: number; secondaryPanelOpen: boolean }) => ({
opacity: 0,
filter: "blur(10px)",
translateX: 40,
transition: {
duration: 0.6,
ease: (t: number) => expoIn(t),
delay: 0.05 + (secondaryPanelOpen ? 0.15 : 0) + index * 0.1,
},
}),
};
const mainPanelVariants: Variants = {
initial: { opacity: 0, filter: "blur(10px)", translateY: 20 },
animate: {
opacity: 1,
filter: "blur(0px)",
translateY: 0,
transition: { duration: 0.5, ease: expoOut },
},
exit: ({ isSecondaryPanelOpen }: { isSecondaryPanelOpen: boolean }) => ({
opacity: 0,
filter: "blur(10px)",
translateY: 10,
transition: {
duration: 0.5,
ease: expoIn,
delay: isSecondaryPanelOpen ? 0.1 : 0,
},
}),
};
const secondaryPanelVariants: Variants = {
initial: { opacity: 0, filter: "blur(4px)", translateY: 5 },
animate: {
opacity: 1,
filter: "blur(0px)",
translateY: 0,
transition: { duration: 0.6, type: "spring", bounce: 0 },
},
exit: (isMenuOpen: boolean) => ({
opacity: 0,
filter: isMenuOpen ? "blur(4px)" : "blur(10px)",
translateY: isMenuOpen ? 5 : 10,
transition: isMenuOpen
? { duration: 0.6, type: "spring", bounce: 0 }
: { duration: 0.5, ease: expoIn },
}),
};
/* --------------------------------- Context -------------------------------- */
type FloatingMenuContextValue = {
isMenuOpen: boolean;
toggleMenu: () => void;
closeMenu: () => void;
activeIndex: number | null;
setActiveIndex: Dispatch<SetStateAction<number | null>>;
focusedIndex: number | null;
setFocusedIndex: Dispatch<SetStateAction<number | null>>;
forcedFocusVisibleIndex: number | null;
setForcedFocusVisibleIndex: Dispatch<SetStateAction<number | null>>;
triggerRef: RefObject<HTMLButtonElement | null>;
itemsRefs: RefObject<HTMLButtonElement[]>;
openedWithPointerRef: RefObject<boolean>;
};
const FloatingMenuContext = createContext<FloatingMenuContextValue | null>(null);
const useFloatingMenu = () => {
const ctx = useContext(FloatingMenuContext);
if (!ctx) {
throw new Error("FloatingMenu.* must be used inside <FloatingMenu.Root>");
}
return ctx;
};
/* --------------------------------- Trigger -------------------------------- */
const Trigger = ({ className, ...props }: HTMLMotionProps<"button">) => {
const { isMenuOpen, toggleMenu, triggerRef, openedWithPointerRef } =
useFloatingMenu();
const handlePointerDown = () => {
openedWithPointerRef.current = true;
};
return (
<motion.button
ref={triggerRef}
type="button"
whileTap={{ scale: 0.95 }}
onPointerDown={handlePointerDown}
onClick={toggleMenu}
aria-expanded={isMenuOpen}
aria-haspopup="true"
data-focused={document && (triggerRef.current === document.activeElement)}
data-open={isMenuOpen}
aria-controls="floating-menu-main"
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
className={cn(
"size-8 md:size-10 text-sm cursor-pointer relative z-10 aspect-square rounded-full backdrop-blur-sm pointer-events-auto grid place-items-center",
"bg-black/10 dark:bg-white/5 border border-black/20 dark:border-white/5 text-[#2b2a2a] dark:text-white hover:border-black/50 dark:hover:border-white/20",
"transition-colors duration-500",
"data-[focused=true]:outline-none data-[focused=true]:border-black data-[focused=true]:dark:border-white/20",
className
)}
{...props}
>
<AnimatePresence mode="wait" initial={false}>
{isMenuOpen ? (
<motion.svg
key="close-icon"
viewBox="0 0 24 24"
className="size-3 [grid-area:1/1]"
initial={{ rotate: -45 }}
animate={{ rotate: 0 }}
exit={{ rotate: -45 }}
transition={{ duration: 0.5, ease: backOut }}
>
<motion.path
strokeWidth="3"
d="M 0,0 L 24,24"
stroke="currentColor"
/>
<motion.path
strokeWidth="3"
d="M 24,0 L 0,24"
stroke="currentColor"
initial={{ rotate: 90 }}
animate={{ rotate: 0 }}
exit={{ rotate: 90 }}
transition={{ duration: 0.25, ease: backOut }}
/>
</motion.svg>
) : (
<motion.svg
key="open-icon"
viewBox="0 0 24 24"
className="size-4 [grid-area:1/1]"
transition={{ duration: 0.5, ease: backIn }}
>
<motion.path
strokeWidth="3"
d="M 0,3 L 24,3"
stroke="currentColor"
strokeLinecap="round"
initial={{ opacity: 0, translateY: 10 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: 10 }}
/>
<motion.path
strokeWidth="3"
d="M 0,12 L 24,12"
stroke="currentColor"
strokeLinecap="round"
/>
<motion.path
strokeWidth="3"
d="M 0,21 L 24,21"
stroke="currentColor"
strokeLinecap="round"
initial={{ opacity: 0, translateY: -10 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: -10 }}
/>
</motion.svg>
)}
</AnimatePresence>
</motion.button>
);
};
Trigger.displayName = "FloatingMenu.Trigger";
/* ----------------------------------- Nav ---------------------------------- */
type NavProps = {
children: ReactNode;
className?: string;
};
const Nav = ({ children, className }: NavProps) => {
const { isMenuOpen, itemsRefs, setForcedFocusVisibleIndex, triggerRef } =
useFloatingMenu();
const childArray = Children.toArray(children).filter(isValidElement);
const handleKeyDown = (e: KeyboardEvent) => {
setForcedFocusVisibleIndex(null);
if (e.key === "Tab") {
e.preventDefault();
triggerRef.current?.focus();
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const currentIndex = itemsRefs.current.findIndex(
(r) => r === document.activeElement
);
if (currentIndex === -1 || childArray.length === 0) {
return;
}
const direction = e.key === "ArrowRight" ? 1 : -1;
const nextIndex =
(currentIndex + direction + childArray.length) % childArray.length;
itemsRefs.current[nextIndex]?.focus();
}
};
return (
<AnimatePresence>
{isMenuOpen && (
<motion.nav
aria-label="Menu options"
onKeyDown={handleKeyDown}
className={cn("flex items-center gap-2 flex-1", className)}
>
{childArray.map((child, index) =>
cloneElement(child as ReactElement<any>, { index, key: index })
)}
</motion.nav>
)}
</AnimatePresence>
);
};
Nav.displayName = "FloatingMenu.Nav";
/* --------------------------------- NavItem -------------------------------- */
type NavItemProps = {
children?: ReactNode;
className?: string;
/** Injected by Nav, do not pass manually */
index?: number;
};
const NavItem = ({ children, className, index = 0 }: NavItemProps) => {
const {
activeIndex,
setActiveIndex,
itemsRefs,
focusedIndex,
setFocusedIndex,
forcedFocusVisibleIndex,
setForcedFocusVisibleIndex,
} = useFloatingMenu();
const isActive = activeIndex === index;
const setRef = useCallback(
(el: HTMLButtonElement | null) => {
if (el) {
itemsRefs.current[index] = el;
}
},
[index, itemsRefs]
);
const handleClick = () => {
if (!isActive) {
setActiveIndex(index);
}
};
return (
<motion.button
ref={setRef}
type="button"
variants={navItemVariants}
initial="closed"
animate="open"
exit="closed"
custom={{ index, secondaryPanelOpen: activeIndex !== null }}
onClick={handleClick}
onPointerDown={() => setForcedFocusVisibleIndex(null)}
onFocus={() => setFocusedIndex(index)}
onBlur={() =>
setFocusedIndex((prev) => (prev === index ? null : prev))
}
aria-expanded={isActive}
aria-controls={`floating-menu-secondary-${index}`}
aria-haspopup="true"
aria-selected={isActive}
data-focused={forcedFocusVisibleIndex === index && focusedIndex === index}
data-active={isActive}
className={cn(
"rounded-xl flex-1 py-1",
"cursor-pointer transition-colors duration-500",
"text-sm dark:text-white text-[#2b2a2a] z-5 backdrop-blur-sm",
"bg-black/10 dark:bg-white/5",
"border border-black/20 dark:border-white/5 hover:border-black/50 dark:hover:border-white/20",
"focus:outline-none focus-visible:border-black focus-visible:dark:border-white/20",
"data-[focused=true]:border-black data-[focused=true]:dark:border-white/20",
"data-[active=true]:dark:bg-white/10 data-[active=true]:bg-black/20",
className
)}
>
{children}
</motion.button>
);
};
NavItem.displayName = "FloatingMenu.NavItem";
/* -------------------------------- MainPanel ------------------------------- */
type MainPanelProps = {
children?: ReactNode;
className?: string;
};
const MainPanel = ({ children, className }: MainPanelProps) => {
const { isMenuOpen, activeIndex } = useFloatingMenu();
return (
<AnimatePresence>
{isMenuOpen && (
<motion.div
key="main-panel"
id="floating-menu-main"
role="region"
aria-label="Core panel"
custom={{ isSecondaryPanelOpen: activeIndex !== null }}
variants={mainPanelVariants}
initial="initial"
animate="animate"
exit="exit"
className={cn(
"w-full rounded-xl overflow-hidden backdrop-blur-sm",
"bg-black/10 dark:bg-white/5 border border-black/20 dark:border-white/5",
"text-sm text-[#2b2a2a] dark:text-white flex flex-col gap-2 p-3",
"transition-colors duration-500",
className
)}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
MainPanel.displayName = "FloatingMenu.MainPanel";
/* ----------------------------- SecondaryPanel ----------------------------- */
type SecondaryPanelProps = {
children?: ReactNode;
className?: string;
};
const SecondaryPanel = ({ children, className }: SecondaryPanelProps) => {
const { activeIndex, isMenuOpen, setActiveIndex } = useFloatingMenu();
const items = Children.toArray(children).filter(isValidElement);
const defaultOpenItemIndex = items.findIndex(
(child) => (child.props as SecondaryPanelItemProps).defaultOpen
);
useEffect(() => {
if (isMenuOpen && defaultOpenItemIndex > -1) {
setActiveIndex(defaultOpenItemIndex);
}
}, [isMenuOpen, defaultOpenItemIndex, setActiveIndex]);
const activeItem =
activeIndex !== null && activeIndex < items.length
? items[activeIndex]
: null;
return (
<AnimatePresence mode="wait" custom={isMenuOpen}>
{activeItem && isMenuOpen && (
<motion.div
role="region"
aria-label={`Secondary panel ${activeIndex !== null && `for option ${activeIndex + 1}` }`}
key={activeIndex}
id={`floating-menu-secondary-${activeIndex}`}
aria-live="polite"
custom={isMenuOpen}
variants={secondaryPanelVariants}
initial="initial"
animate="animate"
exit="exit"
className={cn(
"text-sm rounded-xl p-3 bg-black/10 dark:bg-white/5 backdrop-blur-sm",
"border border-black/20 dark:border-white/5 w-full",
"text-[#2b2a2a] dark:text-white",
className
)}
>
{activeItem}
</motion.div>
)}
</AnimatePresence>
);
};
SecondaryPanel.displayName = "FloatingMenu.SecondaryPanel";
/* --------------------------- SecondaryPanelItem --------------------------- */
type SecondaryPanelItemProps = {
children?: ReactNode;
className?: string;
/** When true, this item is auto-activated whenever the menu opens. */
defaultOpen?: boolean;
};
const SecondaryPanelItem = ({ children, className }: SecondaryPanelItemProps) => {
if (className) {
return <div className={className}>{children}</div>;
}
return children;
};
SecondaryPanelItem.displayName = "FloatingMenu.SecondaryPanelItem";
/* ---------------------------------- Root ---------------------------------- */
const Root = ({ children, className, ...props }: React.ComponentProps<"div">) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [forcedFocusVisibleIndex, setForcedFocusVisibleIndex] = useState<
number | null
>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const itemsRefs = useRef<HTMLButtonElement[]>([]);
const openedWithPointerRef = useRef(false);
const toggleMenu = useCallback(() => {
setIsMenuOpen((o) => !o);
setActiveIndex(null);
}, []);
const closeMenu = useCallback(() => {
setIsMenuOpen(false);
setActiveIndex(null);
triggerRef.current?.focus();
}, []);
useEffect(() => {
if (isMenuOpen) {
itemsRefs.current[0]?.focus();
if (openedWithPointerRef.current) {
setForcedFocusVisibleIndex(0);
}
} else {
setFocusedIndex(null);
setForcedFocusVisibleIndex(null);
}
openedWithPointerRef.current = false;
}, [isMenuOpen]);
let trigger: ReactNode = null;
let nav: ReactNode = null;
let main: ReactNode = null;
let secondary: ReactNode = null;
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
const t = child.type;
if (t === Trigger) {
trigger = child;
} else if (t === Nav) {
nav = child;
} else if (t === MainPanel) {
main = child;
} else if (t === SecondaryPanel) {
secondary = child;
}
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeMenu();
}
};
const value = useMemo<FloatingMenuContextValue>(
() => ({
isMenuOpen,
toggleMenu,
closeMenu,
activeIndex,
setActiveIndex,
focusedIndex,
setFocusedIndex,
forcedFocusVisibleIndex,
setForcedFocusVisibleIndex,
triggerRef,
itemsRefs,
openedWithPointerRef,
}),
[
isMenuOpen,
toggleMenu,
closeMenu,
activeIndex,
focusedIndex,
forcedFocusVisibleIndex,
]
);
return (
<FloatingMenuContext.Provider value={value}>
<div
role="menu"
aria-label="Main Menu"
className={cn(
"absolute z-99",
"flex flex-col items-end gap-2 w-84",
!isMenuOpen && "pointer-events-none",
className
)}
{...props}
onKeyDown={handleKeyDown}
>
<div className="flex items-center justify-end gap-2 w-full">
{nav}
{trigger}
</div>
{main}
{secondary}
</div>
</FloatingMenuContext.Provider>
);
};
Root.displayName = "FloatingMenu.Root";
/* --------------------------------- Export --------------------------------- */
const FloatingMenu = {
Root,
Trigger,
Nav,
NavItem,
MainPanel,
SecondaryPanel,
SecondaryPanelItem,
};
export { FloatingMenu };import { FloatingMenu } from "@/components/floating-menu";
return(
<FloatingMenu.Root className="top-12 right-12">
<FloatingMenu.Trigger />
<FloatingMenu.Nav>
{OPTIONS.map((opt) => (
<FloatingMenu.NavItem key={opt.id}>{opt.label}</FloatingMenu.NavItem>
))}
</FloatingMenu.Nav>
<FloatingMenu.MainPanel>
Main content panel
</FloatingMenu.MainPanel>
<FloatingMenu.SecondaryPanel>
{OPTIONS.map((opt) => (
<FloatingMenu.SecondaryPanelItem key={opt.id}>
{opt.content}
</FloatingMenu.SecondaryPanelItem>
))}
</FloatingMenu.SecondaryPanel>
</FloatingMenu.Root>
)<FloatingMenu.Root className="top-12 right-12">
<FloatingMenu.Trigger />
<FloatingMenu.Nav>
{OPTIONS.map((opt) => (
<FloatingMenu.NavItem key={opt.id}>{opt.label}</FloatingMenu.NavItem>
))}
</FloatingMenu.Nav>
<FloatingMenu.MainPanel>
Main content panel
</FloatingMenu.MainPanel>
<FloatingMenu.SecondaryPanel>
{OPTIONS.map((opt) => (
<FloatingMenu.SecondaryPanelItem key={opt.id} defaultOpen={opt.id === "o1"}>
{opt.content}
</FloatingMenu.SecondaryPanelItem>
))}
</FloatingMenu.SecondaryPanel>
</FloatingMenu.Root><FloatingMenu.Root className="top-12 right-12">
<FloatingMenu.Trigger />
<FloatingMenu.Nav>
{OPTIONS.map((opt) => (
<FloatingMenu.NavItem key={opt.id}>{opt.label}</FloatingMenu.NavItem>
))}
</FloatingMenu.Nav>
{showMainPanel && (
<FloatingMenu.MainPanel>
Main content panel
</FloatingMenu.MainPanel>
)}
<FloatingMenu.SecondaryPanel>
{OPTIONS.map((opt) => (
<FloatingMenu.SecondaryPanelItem key={opt.id}>
{opt.content}
</FloatingMenu.SecondaryPanelItem>
))}
</FloatingMenu.SecondaryPanel>
</FloatingMenu.Root>Adheres to the WAI-ARIA Menu Button pattern.
| Key | Description |
|---|---|
Tab | Menu closed: focus moves to the trigger; activate it with Enter or Space to open. Menu open, focus on a NavItem: default tabbing is prevented and focus jumps to the trigger. This exits the nav row before panel content. Menu open, focus on the trigger: native Tab moves to the next focusable in DOM order: interactive elements inside MainPanel, then SecondaryPanel, then the rest of the page. Use ArrowLeft / ArrowRight to move between nav items without leaving the row. |
Shift + Tab | Native reverse tab order. Not intercepted by the component; use it to move backward through trigger, panels, and external focusables. |
ArrowRight / ArrowLeft | Menu open, focus inside Nav: moves focus between NavItems horizontally; wraps from the last item to the first and vice versa. Clears the pointer-only focus ring on the first item. |
Enter / Space | On the trigger: toggles the menu. On a NavItem: activates that option and shows its SecondaryPanelItem (same as click). |
Escape | Menu open: closes the menu, clears the active secondary panel, and returns focus to the trigger. |
FloatingMenu is a compound component. Use FloatingMenu.Root as the wrapper and place exactly one of each slot child — Trigger, Nav, MainPanel, and SecondaryPanel — as direct children.
Container that owns menu state, focus management, and layout. Renders the trigger row (Nav + Trigger), then MainPanel, then SecondaryPanel.
Accepts all standard div props.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Must include FloatingMenu.Trigger, FloatingMenu.Nav, and optionally FloatingMenu.MainPanel and FloatingMenu.SecondaryPanel as direct children. |
className | string | — | Positioning and layout classes (e.g. top-12 right-12). The root is absolute with w-84 by default. |
Animated open/close button with hamburger and close icons.
Extends HTMLMotionProps<"button"> from Motion.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional classes merged onto the default circular trigger styles. |
Horizontal list of nav items, shown only while the menu is open.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | FloatingMenu.NavItem elements. Each child receives an index prop automatically — do not pass it manually. |
className | string | — | Classes for the motion.nav wrapper (flex items-center gap-2 flex-1 by default). |
Button for a single menu option. Activates the matching SecondaryPanelItem at the same index.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Label or content shown in the nav bar. |
className | string | — | Additional classes for the nav button. |
index | number | 0 | Injected by Nav. Do not set manually. |
Primary content region below the nav row. Mounts only while the menu is open.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Static or interactive content for the main panel. |
className | string | — | Additional classes for the panel container. |
Swaps in the SecondaryPanelItem whose index matches the active NavItem.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | One FloatingMenu.SecondaryPanelItem per NavItem, in the same order. |
className | string | — | Additional classes for the secondary panel container. |
Content slot for a single secondary view. Not rendered until its index is active.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Panel body shown when this item is active. |
className | string | — | When set, wraps children in a div with these classes. |
defaultOpen | boolean | false | When true, this item is auto-selected whenever the menu opens. Only one item should use this per menu. |