Menu
A menu displays a list of actions or options that a user can choose. Supports sections, submenus, separators, selection, and disabled items with full keyboard navigation.
The Menu component displays a list of actions or options that a user can choose. Built on React Aria's Menu primitive with full keyboard navigation, submenus, sections, and accessibility.
Basic Usage
A simple menu triggered by a button. Each item fires an onAction callback when selected.
"use client";
import { MenuTrigger, Menu, MenuItem } from "@repo/ui/components/menu";
import { Button } from "@repo/ui/components/button";
/* BASIC MENU */
export const Example1 = () => {
return (
<MenuTrigger>
<Button variant="outline">Actions</Button>
<Menu>
<MenuItem onAction={() => alert("New file")}>
New file…
</MenuItem>
<MenuItem onAction={() => alert("Open")}>Open…</MenuItem>
<MenuItem onAction={() => alert("Save")}>Save</MenuItem>
<MenuItem onAction={() => alert("Save As")}>Save as…</MenuItem>
<MenuItem onAction={() => alert("Print")}>Print…</MenuItem>
</Menu>
</MenuTrigger>
);
};
Sections
Use MenuSection with a title to group related items with sticky section headers.
"use client";
import {
MenuTrigger,
Menu,
MenuItem,
MenuSection,
} from "@repo/ui/components/menu";
import { Button } from "@repo/ui/components/button";
/* SECTIONS WITH HEADERS */
export const Example2 = () => {
return (
<MenuTrigger>
<Button variant="outline">Publish</Button>
<Menu>
<MenuSection title="Export">
<MenuItem id="image">Image…</MenuItem>
<MenuItem id="video">Video…</MenuItem>
<MenuItem id="text">Text…</MenuItem>
</MenuSection>
<MenuSection title="Share">
<MenuItem id="youtube">YouTube…</MenuItem>
<MenuItem id="instagram">Instagram…</MenuItem>
<MenuItem id="email">Email…</MenuItem>
</MenuSection>
</Menu>
</MenuTrigger>
);
};
Submenus
Wrap a MenuItem with a SubmenuTrigger to create nested submenus. Submenus can be nested multiple levels deep.
"use client";
import {
MenuTrigger,
SubmenuTrigger,
Menu,
MenuItem,
} from "@repo/ui/components/menu";
import { Button } from "@repo/ui/components/button";
/* SUBMENUS */
export const Example3 = () => {
return (
<MenuTrigger>
<Button variant="outline">Actions</Button>
<Menu>
<MenuItem onAction={() => alert("Cut")}>Cut</MenuItem>
<MenuItem onAction={() => alert("Copy")}>Copy</MenuItem>
<MenuItem onAction={() => alert("Delete")}>Delete</MenuItem>
<SubmenuTrigger>
<MenuItem>Share</MenuItem>
<Menu>
<MenuItem onAction={() => alert("SMS")}>SMS</MenuItem>
<MenuItem onAction={() => alert("Instagram")}>
Instagram
</MenuItem>
<SubmenuTrigger>
<MenuItem>Email</MenuItem>
<Menu>
<MenuItem onAction={() => alert("Work")}>
Work
</MenuItem>
<MenuItem onAction={() => alert("Personal")}>
Personal
</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
);
};
Separators
Use MenuSeparator to create visual dividers between groups of menu items.
"use client";
import {
MenuTrigger,
Menu,
MenuItem,
MenuSeparator,
} from "@repo/ui/components/menu";
import { Button } from "@repo/ui/components/button";
/* SEPARATORS */
export const Example4 = () => {
return (
<MenuTrigger>
<Button variant="outline">File</Button>
<Menu>
<MenuItem onAction={() => alert("New")}>New…</MenuItem>
<MenuItem onAction={() => alert("Open")}>Open…</MenuItem>
<MenuSeparator />
<MenuItem onAction={() => alert("Save")}>Save</MenuItem>
<MenuItem onAction={() => alert("Save As")}>Save as…</MenuItem>
<MenuItem onAction={() => alert("Rename")}>Rename…</MenuItem>
<MenuSeparator />
<MenuItem onAction={() => alert("Page Setup")}>
Page setup…
</MenuItem>
<MenuItem onAction={() => alert("Print")}>Print…</MenuItem>
</Menu>
</MenuTrigger>
);
};
Selection
Set selectionMode to "single" or "multiple" to enable selection. Use selectedKeys and onSelectionChange for controlled selection.
Selected: rulers
"use client";
import { useState } from "react";
import type { Selection } from "react-aria-components";
import { MenuTrigger, Menu, MenuItem } from "@repo/ui/components/menu";
import { Button } from "@repo/ui/components/button";
/* CONTROLLED SELECTION */
export const Example5 = () => {
const [selected, setSelected] = useState<Selection>(new Set(["rulers"]));
return (
<div className="flex flex-col gap-2">
<MenuTrigger>
<Button variant="outline">View</Button>
<Menu
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
<MenuItem id="grid">Pixel grid</MenuItem>
<MenuItem id="rulers">Rulers</MenuItem>
<MenuItem id="guides">Layout guides</MenuItem>
<MenuItem id="toolbar">Toolbar</MenuItem>
</Menu>
</MenuTrigger>
<p className="text-muted-foreground text-sm">
Selected:{" "}
{selected === "all"
? "all"
: [...selected].join(", ") || "none"}
</p>
</div>
);
};
Disabled Items
Use isDisabled on individual MenuItem components to prevent interaction.
"use client";
import { MenuTrigger, Menu, MenuItem } from "@repo/ui/components/menu";
import { Button } from "@repo/ui/components/button";
/* DISABLED ITEMS */
export const Example6 = () => {
return (
<MenuTrigger>
<Button variant="outline">Permissions</Button>
<Menu>
<MenuItem id="read">Read</MenuItem>
<MenuItem id="write">Write</MenuItem>
<MenuItem id="admin" isDisabled>
Admin (restricted)
</MenuItem>
<MenuItem id="delete" isDisabled>
Delete (restricted)
</MenuItem>
<MenuItem id="comment">Comment</MenuItem>
</Menu>
</MenuTrigger>
);
};
Component Code
"use client";
import { Check, ChevronRight } from "lucide-react";
import React from "react";
import {
Menu as AriaMenu,
MenuItem as AriaMenuItem,
MenuProps,
MenuItemProps,
MenuSection as AriaMenuSection,
MenuSectionProps as AriaMenuSectionProps,
MenuTrigger as AriaMenuTrigger,
SubmenuTrigger as AriaSubmenuTrigger,
Separator,
SeparatorProps,
composeRenderProps,
Header,
Collection,
SubmenuTriggerProps,
MenuTriggerProps as AriaMenuTriggerProps,
Popover as AriaPopover,
PopoverProps as AriaPopoverProps,
} from "react-aria-components";
import { cn, tv } from "tailwind-variants";
import { dropdownItemStyles } from "@repo/ui/components/list-box";
/* -----------------------------------------------------------------------------
* Popover (menu-specific, NO Dialog wrapping)
* ---------------------------------------------------------------------------*/
const menuPopoverStyles = tv({
base: [
"bg-popover text-popover-foreground",
"z-50 min-w-[150px] overflow-auto rounded-md border p-1 shadow-md outline-none",
],
variants: {
isEntering: {
true: "animate-in fade-in-0 zoom-in-95 duration-200",
},
isExiting: {
true: "animate-out fade-out-0 zoom-out-95 duration-150",
},
},
});
/* -----------------------------------------------------------------------------
* MenuTrigger
* ---------------------------------------------------------------------------*/
interface MenuTriggerProps extends AriaMenuTriggerProps {
placement?: AriaPopoverProps["placement"];
}
export function MenuTrigger(props: MenuTriggerProps) {
const [trigger, menu] = React.Children.toArray(props.children) as [
React.ReactElement,
React.ReactElement,
];
return (
<AriaMenuTrigger data-slot="menu-trigger" {...props}>
{trigger}
<AriaPopover
placement={props.placement}
offset={8}
className={composeRenderProps(
"" as string,
(className, renderProps) =>
cn(menuPopoverStyles({ ...renderProps }), className) ||
"",
)}
>
{menu}
</AriaPopover>
</AriaMenuTrigger>
);
}
/* -----------------------------------------------------------------------------
* SubmenuTrigger
* ---------------------------------------------------------------------------*/
export function SubmenuTrigger(props: SubmenuTriggerProps) {
const [trigger, menu] = React.Children.toArray(props.children) as [
React.ReactElement,
React.ReactElement,
];
return (
<AriaSubmenuTrigger data-slot="submenu-trigger" {...props}>
{trigger}
<AriaPopover
offset={-2}
crossOffset={-4}
className={composeRenderProps(
"" as string,
(className, renderProps) =>
cn(menuPopoverStyles({ ...renderProps }), className) ||
"",
)}
>
{menu}
</AriaPopover>
</AriaSubmenuTrigger>
);
}
/* -----------------------------------------------------------------------------
* Menu
* ---------------------------------------------------------------------------*/
export function Menu<T extends object>(props: MenuProps<T>) {
return (
<AriaMenu
data-slot="menu"
{...props}
className={cn(
"max-h-[inherit] overflow-auto font-sans outline-none [clip-path:inset(0_0_0_0_round_.75rem)]",
props.className,
)}
/>
);
}
/* -----------------------------------------------------------------------------
* MenuItem
* ---------------------------------------------------------------------------*/
export function MenuItem(props: MenuItemProps) {
const textValue =
props.textValue ||
(typeof props.children === "string" ? props.children : undefined);
return (
<AriaMenuItem
data-slot="menu-item"
textValue={textValue}
{...props}
className={dropdownItemStyles}
>
{composeRenderProps(
props.children,
(children, { selectionMode, isSelected, hasSubmenu }) => (
<>
{selectionMode !== "none" && (
<span className="flex w-4 items-center">
{isSelected && (
<Check aria-hidden className="h-4 w-4" />
)}
</span>
)}
<span className="group-selected:font-semibold flex flex-1 items-center gap-2 truncate font-normal">
{children}
</span>
{hasSubmenu && (
<ChevronRight
aria-hidden
className="absolute right-2 h-4 w-4"
/>
)}
</>
),
)}
</AriaMenuItem>
);
}
/* -----------------------------------------------------------------------------
* MenuSeparator
* ---------------------------------------------------------------------------*/
export function MenuSeparator(props: SeparatorProps) {
return (
<Separator
data-slot="menu-separator"
{...props}
className="border-border mx-3 my-1 border-b"
/>
);
}
/* -----------------------------------------------------------------------------
* MenuSection
* ---------------------------------------------------------------------------*/
export interface MenuSectionProps<T> extends AriaMenuSectionProps<T> {
title?: string;
items?: Iterable<T>;
}
export function MenuSection<T extends object>(props: MenuSectionProps<T>) {
return (
<AriaMenuSection
data-slot="menu-section"
{...props}
className="after:block after:h-[5px] after:content-[''] first:-mt-[5px]"
>
{props.title && (
<Header className="text-muted-foreground border-y-border bg-muted/60 supports-[-moz-appearance:none]:bg-muted sticky -top-[5px] z-10 -mx-1 -mt-px truncate border-y px-4 py-1 text-sm font-semibold backdrop-blur-md [&+*]:mt-1">
{props.title}
</Header>
)}
<Collection items={props.items}>{props.children}</Collection>
</AriaMenuSection>
);
}
Loader
A customizable loading spinner and progress indicator. signals wait times effectively with smooth animations and multiple size and color variants.
Pagination
A powerful pagination component for navigating large datasets. Includes page buttons, navigation controls, and page size selectors with full keyboard support.