Menu
Trigger-anchored action menu built on react-aria-components and styled with the quebi design system. Composes the popover overlay and the shared dropdown item styling, with sections, separators, keyboard shortcuts, intents, and nested submenus. Foundational for command-menu and context-menu.
Row actions
A trigger opens a menu of actions, with a danger intent for destructive ones.
Sections
Group related items under labelled sections divided by separators.
Icons and shortcuts
Items can carry leading icons and trailing keyboard shortcut hints.
Descriptions and header
Items support two-line descriptions, and the menu can carry a header.
Plain text trigger and submenu
Use `MenuTrigger` for an unstyled inline trigger, and `MenuSubMenu` to nest a submenu.
Source
Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { Check, ChevronRight } from "lucide-react"
import type {
ButtonProps,
MenuItemProps as MenuItemPrimitiveProps,
MenuProps as MenuPrimitiveProps,
MenuSectionProps as MenuSectionPrimitiveProps,
MenuTriggerProps as MenuTriggerPrimitiveProps,
} from "react-aria-components"
import {
Button,
Collection,
composeRenderProps,
Header,
MenuItem as MenuItemPrimitive,
Menu as MenuPrimitive,
MenuSection as MenuSectionPrimitive,
MenuTrigger as MenuTriggerPrimitive,
SubmenuTrigger as SubmenuTriggerPrimitive,
} from "react-aria-components"
import { tv, type VariantProps } from "tailwind-variants"
import {
DropdownDescription,
DropdownKeyboard,
DropdownLabel,
DropdownSeparator,
dropdownItemStyles,
dropdownSectionStyles,
} from "@/components/dropdown"
import { PopoverContent, type PopoverContentProps } from "@/components/popover"
import { cn } from "@/lib/utils"
/**
* Menu — quebi design system
*
* A trigger-anchored action menu built on react-aria-components and composed from
* the Popover overlay and the shared Dropdown item styling. Supports sections,
* separators, keyboard shortcuts, selection indicators, danger/warning intents,
* and nested submenus. Foundational — command-menu and context-menu compose this.
*
* Surface: the dark quebi-bg popover with cyan hairlines; items reuse the
* dropdown item styling (brand-teal selection, subtle white hover/focus wash).
*/
const Menu = (props: MenuTriggerPrimitiveProps) => <MenuTriggerPrimitive {...props} />
const MenuSubMenu = ({ delay = 0, ...props }: React.ComponentProps<typeof SubmenuTriggerPrimitive>) => (
<SubmenuTriggerPrimitive {...props} delay={delay}>
{props.children}
</SubmenuTriggerPrimitive>
)
interface MenuTriggerProps extends ButtonProps {
ref?: React.Ref<HTMLButtonElement>
}
const MenuTrigger = ({ className, ref, ...props }: MenuTriggerProps) => (
<Button
ref={ref}
data-slot="menu-trigger"
className={cn(
"relative inline text-start outline-hidden",
"focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
"*:data-[slot=chevron]:size-5 sm:*:data-[slot=chevron]:size-4",
className,
)}
{...props}
/>
)
interface MenuContentProps<T>
extends MenuPrimitiveProps<T>,
Pick<PopoverContentProps, "placement"> {
className?: string
popover?: Pick<
PopoverContentProps,
| "arrow"
| "className"
| "placement"
| "offset"
| "crossOffset"
| "arrowBoundaryOffset"
| "triggerRef"
| "isOpen"
| "onOpenChange"
| "shouldFlip"
>
}
const menuContentStyles = tv({
base: "grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-y-auto overflow-x-hidden overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_calc(var(--radius-quebi-md)-(--spacing(1))))] [&>[data-slot=menu-section]+[data-slot=menu-section]:not([class*='mt-']):not([class*='my-'])]:mt-3",
})
const MenuContent = <T extends object>({
className,
placement,
popover,
...props
}: MenuContentProps<T>) => {
return (
<PopoverContent
className={cn("min-w-32 *:data-[slot=popover-inner]:overflow-hidden", popover?.className)}
placement={placement}
{...popover}
>
<MenuPrimitive
data-slot="menu-content"
className={menuContentStyles({ className })}
{...props}
/>
</PopoverContent>
)
}
interface MenuItemProps extends MenuItemPrimitiveProps, VariantProps<typeof dropdownItemStyles> {}
const MenuItem = ({ className, intent, children, ...props }: MenuItemProps) => {
const textValue = props.textValue || (typeof children === "string" ? children : undefined)
return (
<MenuItemPrimitive
data-slot="menu-item"
className={composeRenderProps(className, (className, { hasSubmenu, ...renderProps }) =>
dropdownItemStyles({
...renderProps,
intent,
className: hasSubmenu
? cn(
// Open-submenu state — match the hovered / focused state from
// dropdownItemStyles so an expanded parent reads as active.
intent === "danger" &&
"open:bg-red-500/10 open:text-red-400 open:[&_[data-slot='icon']:not([class*='text-'])]:text-red-400",
intent === "warning" &&
"open:bg-amber-500/10 open:text-amber-400 open:[&_[data-slot='icon']:not([class*='text-'])]:text-amber-400",
intent === undefined &&
"open:bg-white/[0.04] open:text-white open:[&_[data-slot='icon']:not([class*='text-'])]:text-white",
className,
)
: className,
}),
)}
textValue={textValue}
{...props}
>
{(values) => (
<>
{values.isSelected && ["single", "multiple"].includes(values.selectionMode) && (
<Check data-slot="icon" />
)}
{typeof children === "function" ? children(values) : children}
{values.hasSubmenu && (
<ChevronRight data-slot="chevron" className="absolute end-2 size-3.5" />
)}
</>
)}
</MenuItemPrimitive>
)
}
export interface MenuHeaderProps extends React.ComponentProps<typeof Header> {
separator?: boolean
}
const MenuHeader = ({ className, separator = false, ...props }: MenuHeaderProps) => (
<Header
className={cn(
"col-span-full px-2.5 py-2 font-medium text-base text-white sm:text-sm",
separator && "-mx-1 border-cyan-500/10 border-b sm:px-3 sm:pb-2.5",
className,
)}
{...props}
/>
)
const { section, header } = dropdownSectionStyles()
interface MenuSectionProps<T> extends MenuSectionPrimitiveProps<T> {
ref?: React.Ref<HTMLDivElement>
label?: string
}
const MenuSection = <T extends object>({
className,
children,
ref,
...props
}: MenuSectionProps<T>) => {
return (
<MenuSectionPrimitive
data-slot="menu-section"
ref={ref}
className={section({ className })}
{...props}
>
{"label" in props && <Header className={header()}>{props.label}</Header>}
<Collection items={props.items}>{children}</Collection>
</MenuSectionPrimitive>
)
}
const MenuSeparator = DropdownSeparator
const MenuShortcut = DropdownKeyboard
const MenuLabel = DropdownLabel
const MenuDescription = DropdownDescription
export type { MenuContentProps, MenuItemProps, MenuSectionProps, MenuTriggerProps }
export {
Menu,
MenuContent,
MenuDescription,
MenuHeader,
MenuItem,
MenuLabel,
MenuSection,
MenuSeparator,
MenuShortcut,
MenuSubMenu,
MenuTrigger,
menuContentStyles,
}