Menu

Portalled dropdown menu with items, checkbox items, separators and labels.

import { Menu, MenuTrigger, MenuContent, MenuItem, MenuCheckboxItem, MenuSeparator, MenuLabel }from "@/components/ui"

Actions menu

Full source

The complete component, read from src/components/ui/menu.tsx.

menu.tsx
"use client";

import * as React 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: React.RefObject<HTMLElement | null>;
}
const MenuContext = React.createContext<MenuContextValue | null>(null);

function useMenuContext(): MenuContextValue {
  const ctx = React.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: React.ReactNode;
}

export interface MenuContentProps {

  align?: "start" | "end" | "center";

  minWidth?: number;
  children: React.ReactNode;
}

export interface MenuItemProps {
  icon?: React.ReactNode;

  destructive?: boolean;
  disabled?: boolean;
  onSelect?: () => void;
  children: React.ReactNode;
}

export interface MenuCheckboxItemProps {
  checked: boolean;
  onCheckedChange: (checked: boolean) => void;

  locked?: boolean;
  children: React.ReactNode;
}

export function Menu({ open, onOpenChange, children }: MenuProps) {
  const triggerRef = React.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: React.ReactElement }) {
  const { open, onOpenChange, triggerRef } = useMenuContext();

  const child = React.cloneElement(children, {
    ref: triggerRef,
    onClick: (e: React.MouseEvent) => {
      (children.props as React.HTMLAttributes<Element>).onClick?.(e as React.MouseEvent<HTMLElement>);
      onOpenChange(!open);
    },
  } as React.HTMLAttributes<HTMLElement> & { ref: React.Ref<HTMLElement> });

  return child;
}

export function MenuContent({ align = "start", minWidth = 180, children }: MenuContentProps) {
  const { open, onOpenChange, triggerRef } = useMenuContext();
  const contentRef = React.useRef<HTMLDivElement>(null);
  const mounted = useMounted();

  const position = usePopoverPosition(triggerRef as React.RefObject<HTMLElement>, open, {
    align,
    minWidth,
    preferredHeight: 280,
    gap: 4,
  });


  useOutsideClick(
    [triggerRef, contentRef],
    open,
    React.useCallback(() => onOpenChange(false), [onOpenChange])
  );


  React.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: React.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