Base UI menu with submenus, checkbox/radio items, and a spring-driven popup — the dropdown behind mega-menus and sidebar row actions.
Standalone
Actions and shortcuts off an icon trigger.
Checkboxes & radio
Grouped checkbox items alongside a radio group, the settings-menu shape.
Install
Install the packages this component imports.
bun add @base-ui/react motion lucide-reactAlso requires lib/utils.ts, lib/springs.ts, hooks/use-proximity-hover.ts (see Installation).
Source
Copy and paste the following code into your project.
"use client";
import {
Children,
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useRef,
type ComponentProps,
type ReactElement,
type ReactNode,
} from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { motion, AnimatePresence } from "motion/react";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
import {
useProximityHover,
proximityHoverWashClassName,
proximityHoverWashOpacity,
} from "@/hooks/use-proximity-hover";
// ─── Proximity hover ─────────────────────────────────────────────────────────
// Every MenuContent/MenuSubContent popup owns one useProximityHover instance
// — the same measured-rect hover wash Accordion/Tabs use, scoped to that
// popup's own items so a submenu's pill never reaches into its parent's.
// Base UI's own `data-highlighted` (keyboard nav and pointer hover both set
// it) still drives each row's text color, same split Accordion uses between
// its pill-owns-background and item-owns-text-color.
interface MenuProximityContextValue {
registerItem: (index: number, element: HTMLElement | null) => void;
}
const MenuProximityContext = createContext<MenuProximityContextValue | null>(null);
/** Position for proximity hover — auto-assigned by MenuContent's child walk; only present on items it recognized as interactive rows. */
type MenuIndexProp = { _index?: number };
function useMenuItemRegistration(ref: React.RefObject<HTMLElement | null>, index?: number) {
const ctx = useContext(MenuProximityContext);
useEffect(() => {
if (index === undefined || !ctx) return;
ctx.registerItem(index, ref.current);
return () => ctx.registerItem(index, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, ctx]);
}
/**
* Auto-indexes MenuContent's children so callers never hand-thread an index
* just for proximity hover — mirrors Accordion's indexedChildren, but as a
* recursive walk (not a flat Children.map) since indexable rows can sit one
* level down inside a MenuGroup/MenuRadioGroup. MenuSub's own children are
* left alone: its trigger is indexed as a row in *this* popup, but its
* SubContent is a separate popup with its own independent index space.
*/
function indexMenuChildren(children: ReactNode, counter: { current: number }): ReactNode {
return Children.map(children, (child) => {
if (!isValidElement(child)) return child;
if (child.type === MenuGroup || child.type === MenuRadioGroup) {
const groupProps = child.props as { children?: ReactNode };
return cloneElement(child as ReactElement<{ children?: ReactNode }>, {
children: indexMenuChildren(groupProps.children, counter),
});
}
if (
child.type === MenuItem ||
child.type === MenuCheckboxItem ||
child.type === MenuRadioItem ||
child.type === MenuSubTrigger
) {
return cloneElement(child as ReactElement<MenuIndexProp>, { _index: counter.current++ });
}
return child;
});
}
// ─── Menu ────────────────────────────────────────────────────────────────────
function Menu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="menu" {...props} />;
}
function MenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />;
}
function MenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="menu-portal" {...props} />;
}
/**
* Anchored floating list — same elevation step and scale-from-anchor
* entrance as Popover (`--popover`, `origin-(--transform-origin)`,
* `spring.moderate` — the tier the spring table already names for "dropdown
* ... indicators"). Sized to its content (`min-w-40`) rather than matched to
* the trigger's width the way a Select popup would be.
*/
function MenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
children,
...props
}: MenuPrimitive.Popup.Props &
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
const containerRef = useRef<HTMLDivElement>(null);
const { activeIndex, itemRects, handlers, registerItem, measureItems } = useProximityHover(
containerRef,
{ axis: "y" },
);
useEffect(() => {
measureItems();
}, [measureItems, children]);
const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;
const indexedChildren = indexMenuChildren(children, { current: 0 });
return (
<MenuPortal>
<MenuPrimitive.Positioner
data-slot="menu-positioner"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="z-50 outline-none"
>
<MenuPrimitive.Popup
ref={containerRef}
data-slot="menu-content"
render={(popupProps, state) => {
const exiting = state.transitionStatus === "ending";
return (
<motion.div
{...(popupProps as Record<string, unknown>)}
{...(props as Record<string, unknown>)}
onMouseMove={handlers.onMouseMove}
onMouseEnter={handlers.onMouseEnter}
onMouseLeave={handlers.onMouseLeave}
className={cn(
"relative z-50 max-h-(--available-height) min-w-40 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-popover outline-none",
className,
)}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: exiting ? 0 : 1, scale: exiting ? 0.96 : 1 }}
transition={exiting ? spring.moderate.exit : spring.moderate.enter}
>
<AnimatePresence>
{activeRect && (
<motion.div
className={cn(
"pointer-events-none absolute rounded-md",
proximityHoverWashClassName,
)}
initial={{
opacity: 0,
top: activeRect.top,
left: activeRect.left,
width: activeRect.width,
height: activeRect.height,
}}
animate={{
opacity: proximityHoverWashOpacity,
top: activeRect.top,
left: activeRect.left,
width: activeRect.width,
height: activeRect.height,
}}
exit={{ opacity: 0, transition: spring.fast.exit }}
transition={spring.fast.enter}
/>
)}
</AnimatePresence>
<MenuProximityContext.Provider value={{ registerItem }}>
{indexedChildren}
</MenuProximityContext.Provider>
</motion.div>
);
}}
/>
</MenuPrimitive.Positioner>
</MenuPortal>
);
}
function MenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="menu-group" {...props} />;
}
function MenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-label text-muted-foreground uppercase data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function MenuItem({
className,
inset,
variant = "default",
_index,
...props
}: MenuPrimitive.Item.Props &
MenuIndexProp & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
const ref = useRef<HTMLDivElement>(null);
useMenuItemRegistration(ref, _index);
return (
<MenuPrimitive.Item
ref={ref}
data-slot="menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menu-item relative z-10 flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-control text-muted-foreground outline-none transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:text-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:text-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg]:text-muted-foreground data-highlighted:[&_svg]:text-foreground data-[variant=destructive]:[&_svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function MenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="menu-sub" {...props} />;
}
function MenuSubTrigger({
className,
inset,
children,
_index,
...props
}: MenuPrimitive.SubmenuTrigger.Props &
MenuIndexProp & {
inset?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useMenuItemRegistration(ref, _index);
return (
<MenuPrimitive.SubmenuTrigger
ref={ref}
data-slot="menu-sub-trigger"
data-inset={inset}
className={cn(
"relative z-10 flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-control text-muted-foreground outline-none transition-colors select-none data-inset:pl-7 data-highlighted:text-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
/**
* Reuses MenuContent (same popup styling/motion/proximity hover) with
* defaults suited to a submenu's anchor: it opens off its trigger's right
* edge rather than below it. Nests one popup inside another, both already
* at the `--popover` step — there's no elevation level past that on the
* documented ladder yet, so this stays at the same step rather than
* skipping ahead of it.
*/
function MenuSubContent({
align = "start",
alignOffset = -4,
side = "right",
sideOffset = 2,
className,
...props
}: ComponentProps<typeof MenuContent>) {
return (
<MenuContent
data-slot="menu-sub-content"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className={cn("min-w-32", className)}
{...props}
/>
);
}
function MenuCheckboxItem({
className,
children,
checked,
inset,
_index,
...props
}: MenuPrimitive.CheckboxItem.Props &
MenuIndexProp & {
inset?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useMenuItemRegistration(ref, _index);
return (
<MenuPrimitive.CheckboxItem
ref={ref}
data-slot="menu-checkbox-item"
data-inset={inset}
className={cn(
"group/menu-item relative z-10 flex cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-control text-muted-foreground outline-none transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:text-foreground data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="menu-checkbox-item-indicator"
>
{/* Selection indicators are `spring.fast` — the same tier the motion
table names for that exact role. `keepMounted` lets framer play
the pop-out on uncheck instead of Base UI unmounting it first. */}
<MenuPrimitive.CheckboxItemIndicator
keepMounted
render={(indicatorProps, state) => {
const visible = state.checked && state.transitionStatus !== "ending";
return (
<motion.span
{...(indicatorProps as Record<string, unknown>)}
initial={false}
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0.5 }}
transition={visible ? spring.fast.enter : spring.fast.exit}
>
<CheckIcon className="size-3.5" />
</motion.span>
);
}}
/>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function MenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />;
}
function MenuRadioItem({
className,
children,
inset,
_index,
...props
}: MenuPrimitive.RadioItem.Props &
MenuIndexProp & {
inset?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useMenuItemRegistration(ref, _index);
return (
<MenuPrimitive.RadioItem
ref={ref}
data-slot="menu-radio-item"
data-inset={inset}
className={cn(
"group/menu-item relative z-10 flex cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-control text-muted-foreground outline-none transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:text-foreground data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator
keepMounted
render={(indicatorProps, state) => {
const visible = state.checked && state.transitionStatus !== "ending";
return (
<motion.span
{...(indicatorProps as Record<string, unknown>)}
initial={false}
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0.5 }}
transition={visible ? spring.fast.enter : spring.fast.exit}
className="flex size-1.5 rounded-full bg-foreground"
/>
);
}}
/>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function MenuShortcut({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="menu-shortcut"
className={cn(
"ml-auto text-meta text-muted-foreground group-data-highlighted/menu-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Menu,
MenuPortal,
MenuTrigger,
MenuContent,
MenuGroup,
MenuLabel,
MenuItem,
MenuCheckboxItem,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator,
MenuShortcut,
MenuSub,
MenuSubTrigger,
MenuSubContent,
};API Reference
MenuMenuTriggerMenuPortaldefaultOpenfalse | boolean Whether the menu is initially open, uncontrolled. |
open | boolean Controlled open state. |
onOpenChange | (open: boolean) => void Called when the open state changes. |
modaltrue | boolean Whether opening the menu locks page scroll and outside pointer input. |
API Reference
MenuContentside"bottom" | "top" | "right" | "bottom" | "left" Which side of the trigger the menu opens on. |
align"start" | "start" | "center" | "end" Alignment relative to the trigger along that side. |
sideOffset4 | number Gap in pixels between the trigger and the menu. |
API Reference
MenuItemvariant"default" | "default" | "destructive" Visual style — destructive tints the label and highlight red. |
disabledfalse | boolean Whether the item ignores interaction. |
closeOnClicktrue | boolean Whether selecting the item closes the menu. |
API Reference
MenuCheckboxItemMenuRadioItemMenuRadioGroupchecked | boolean (MenuCheckboxItem) Controlled checked state. |
onCheckedChange | (checked: boolean) => void (MenuCheckboxItem) Called when the checked state changes. |
value | unknown (MenuRadioGroup) Controlled selected value; (MenuRadioItem) this item's value. |
onValueChange | (value) => void (MenuRadioGroup) Called when the selected value changes. |
API Reference
MenuSubMenuSubTriggerMenuSubContentchildren | ReactNode MenuSubTrigger's label; MenuSubContent's items — a nested MenuContent. |