Menu
Portalled dropdown menu with items, checkbox items, separators and labels.
import { Menu, MenuTrigger, MenuContent, MenuItem, MenuCheckboxItem, MenuSeparator, MenuLabel }from "@/components/ui"
Specification
A portalled dropdown menu composed of a trigger and content containing items, checkbox items, separators and labels.
Props (12)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controls whether the menu content is shown (Menu). |
| onOpenChange* | (open: boolean) => void | — | Called when the menu requests to open or close (Menu). |
| children* | ReactNode | — | The MenuTrigger and MenuContent subtree (Menu). |
| MenuContent.align | "start" | "end" | "center" | "start" | Horizontal alignment of the content relative to the trigger. |
| MenuContent.minWidth | number | 180 | Minimum width in pixels for the menu content. |
| MenuItem.icon | ReactNode | — | Optional leading icon for a menu item. |
| MenuItem.destructive | boolean | — | Styles the item as a destructive action. |
| MenuItem.disabled | boolean | — | Disables the item and skips it during keyboard navigation. |
| MenuItem.onSelect | () => void | — | Called when the item is chosen. |
| MenuCheckboxItem.checked* | boolean | — | Current checked state of the checkbox item. |
| MenuCheckboxItem.onCheckedChange* | (checked: boolean) => void | — | Called with the next checked state when toggled. |
| MenuCheckboxItem.locked | boolean | — | Prevents the checkbox item from being toggled. |
Actions menu
Full source
The complete component, read from
src/components/ui/menu.tsx.menu.tsx
"use client";
import {
createContext,
useContext,
useRef,
useCallback,
useEffect,
cloneElement,
type RefObject,
type ReactNode,
type ReactElement,
type HTMLAttributes,
type Ref,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePopoverPosition } from "@/hooks/use-popover-position";
import { useMounted } from "@/hooks/use-mounted";
import { useOutsideClick } from "@/hooks/use-outside-click";
interface MenuContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef: RefObject<HTMLElement | null>;
}
const MenuContext = createContext<MenuContextValue | null>(null);
function useMenuContext(): MenuContextValue {
const ctx = useContext(MenuContext);
if (!ctx) throw new Error("Menu compound components must be used inside <Menu>.");
return ctx;
}
export interface MenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}
export interface MenuContentProps {
align?: "start" | "end" | "center";
minWidth?: number;
children: ReactNode;
}
export interface MenuItemProps {
icon?: ReactNode;
destructive?: boolean;
disabled?: boolean;
onSelect?: () => void;
children: ReactNode;
}
export interface MenuCheckboxItemProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
locked?: boolean;
children: ReactNode;
}
export function Menu({ open, onOpenChange, children }: MenuProps) {
const triggerRef = useRef<HTMLElement | null>(null);
return (
<MenuContext.Provider value={{ open, onOpenChange, triggerRef }}>
<span className="relative inline-flex">{children}</span>
</MenuContext.Provider>
);
}
export function MenuTrigger({ children }: { children: ReactElement }) {
const { open, onOpenChange, triggerRef } = useMenuContext();
const child = cloneElement(children, {
ref: triggerRef,
onClick: (e: ReactMouseEvent) => {
(children.props as HTMLAttributes<Element>).onClick?.(e as ReactMouseEvent<HTMLElement>);
onOpenChange(!open);
},
} as HTMLAttributes<HTMLElement> & { ref: Ref<HTMLElement> });
return child;
}
export function MenuContent({ align = "start", minWidth = 180, children }: MenuContentProps) {
const { open, onOpenChange, triggerRef } = useMenuContext();
const contentRef = useRef<HTMLDivElement>(null);
const mounted = useMounted();
const position = usePopoverPosition(triggerRef as RefObject<HTMLElement>, open, {
align,
minWidth,
preferredHeight: 280,
gap: 4,
});
useOutsideClick(
[triggerRef, contentRef],
open,
useCallback(() => onOpenChange(false), [onOpenChange])
);
useEffect(() => {
if (!open) return;
const items = () =>
Array.from<HTMLElement>(
contentRef.current?.querySelectorAll('[role^="menuitem"]:not([disabled])') ?? []
);
const t = setTimeout(() => items()[0]?.focus(), 0);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onOpenChange(false);
(triggerRef.current as HTMLElement | null)?.focus();
return;
}
if (e.key === "Tab") { onOpenChange(false); return; }
const list = items();
if (!list.length) return;
const i = list.indexOf(document.activeElement as HTMLElement);
if (e.key === "ArrowDown") { e.preventDefault(); list[(i + 1) % list.length]?.focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); list[(i - 1 + list.length) % list.length]?.focus(); }
};
document.addEventListener("keydown", onKey);
return () => { clearTimeout(t); document.removeEventListener("keydown", onKey); };
}, [open, onOpenChange, triggerRef]);
if (!open || !position || !mounted) return null;
return createPortal(
<div
ref={contentRef}
role="menu"
className="z-50 overflow-auto rounded-lg border border-border bg-surface py-1 shadow-lg"
style={position.style}
>
{children}
</div>,
document.body
);
}
export function MenuItem({ icon, destructive, disabled, onSelect, children }: MenuItemProps) {
const { onOpenChange, triggerRef } = useMenuContext();
const handleClick = () => {
onSelect?.();
onOpenChange(false);
(triggerRef.current as HTMLElement | null)?.focus();
};
return (
<button
type="button"
role="menuitem"
tabIndex={-1}
disabled={disabled}
onClick={handleClick}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent",
"disabled:cursor-not-allowed disabled:opacity-45",
destructive ? "text-error hover:bg-error-light" : "text-text hover:bg-bg-hover"
)}
>
{icon && <span className="shrink-0">{icon}</span>}
<span className="flex-1 truncate">{children}</span>
</button>
);
}
export function MenuCheckboxItem({ checked, onCheckedChange, locked, children }: MenuCheckboxItemProps) {
return (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked}
tabIndex={-1}
disabled={locked}
onClick={() => !locked && onCheckedChange(!checked)}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-text outline-none transition-colors",
"hover:bg-bg-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent",
"disabled:cursor-not-allowed"
)}
>
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border transition-colors",
checked ? "border-accent bg-accent" : "border-border bg-transparent"
)}
>
{checked && <Check size={11} strokeWidth={3} className="text-on-accent" />}
</span>
<span className="flex-1 truncate">{children}</span>
{locked && <span className="shrink-0 text-xs text-text-tertiary">always on</span>}
</button>
);
}
export function MenuSeparator() {
return <div role="separator" className="my-1 h-px bg-border" />;
}
export function MenuLabel({ children }: { children: ReactNode }) {
return (
<p className="px-3 pb-1 pt-1.5 text-xs font-medium uppercase tracking-wider text-text-tertiary">
{children}
</p>
);
}Dependencies
To use Menu you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ └─ menu.tsx src/hooks/ ├─ use-mounted.ts └─ use-popover-position.ts src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/hooks/use-mounted.ts
use-mounted.ts
"use client";
import { useState, useEffect } from "react";
/**
* Returns true once the component has mounted on the client.
* Useful for portals and anything that needs `document` to exist.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";
import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";
interface PopoverPositionOptions {
align?: "start" | "center" | "end";
gap?: number;
viewportPadding?: number;
preferredHeight?: number;
matchWidth?: boolean;
minWidth?: number;
}
interface PopoverPosition {
placement: "top" | "bottom";
style: CSSProperties;
}
/**
* Compute a fixed-position popover style anchored to a ref element.
* Opens downward by default; flips upward when there isn't room below
* AND there's more room above. Clamps horizontally so nothing renders
* past the viewport edge. Re-measures on scroll/resize while open.
*/
export function usePopoverPosition(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
options: PopoverPositionOptions = {}
): PopoverPosition | null {
const {
align = "start",
gap = 4,
viewportPadding = 8,
preferredHeight = 320,
matchWidth = false,
minWidth,
} = options;
const [position, setPosition] = useState<PopoverPosition | null>(null);
const measure = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const placement =
spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
? "top"
: "bottom";
const maxHeight = Math.max(
120,
Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
);
const width = matchWidth ? rect.width : minWidth;
let left = align === "end" && width ? rect.right - width : rect.left;
if (align === "center" && width)
left = rect.left + rect.width / 2 - width / 2;
left = width
? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
: Math.min(Math.max(left, viewportPadding), vw - viewportPadding);
setPosition({
placement,
style: {
position: "fixed",
top: placement === "top" ? undefined : rect.bottom + gap,
bottom: placement === "top" ? vh - rect.top + gap : undefined,
left,
width: matchWidth ? rect.width : undefined,
minWidth: !matchWidth ? minWidth : undefined,
maxHeight,
},
});
}, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
return position;
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}