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.
PropTypeDefaultDescription
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.minWidthnumber180Minimum width in pixels for the menu content.
MenuItem.iconReactNode—Optional leading icon for a menu item.
MenuItem.destructiveboolean—Styles the item as a destructive action.
MenuItem.disabledboolean—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.lockedboolean—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 };
}