@cf/ui

@cf/dropdown-menu

Dropdown Menu

A list of actions or toggles opened from a trigger, with groups, labels, submenus, checkbox and radio items.

The radio tick sits on the right and the checkbox tick on the left, a split I haven't resolved yet (more on that below).

Base UI's Menu under shadcn's names. Rows are 16px text with extra padding below md, the same as select items, so they're tappable without a separate mobile menu.

The two indicators sit on different sides and that's not fully resolved. The radio tick is on the right, where an item's state belongs, and it's a tick rather than a dot because a dot at that size reads as a stray mark. The checkbox tick is still on the left with a pl-8 inset. Both carry data-slot="menu-item-indicator" so a row that shows its state another way, like the notes visibility rows with a Switch, can hide the tick.

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
 
<DropdownMenu>
  <DropdownMenuTrigger render={<Button aria-label="Note actions" size="icon-sm" variant="ghost" />}>
    <DotsThreeIcon data-icon />
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end">
    <DropdownMenuGroup>
      <DropdownMenuItem onClick={rename}>Rename</DropdownMenuItem>
    </DropdownMenuGroup>
    <DropdownMenuSeparator />
    <DropdownMenuGroup>
      <DropdownMenuItem onClick={() => setDeleting(true)} variant="destructive">
        Delete
      </DropdownMenuItem>
    </DropdownMenuGroup>
  </DropdownMenuContent>
</DropdownMenu>

Props

PropTypeDefault
DropdownMenuContent alignstart | center | endstart
DropdownMenuContent sidetop | right | bottom | left | inline-start | inline-endbottom
DropdownMenuContent sideOffsetnumber4
DropdownMenuItem variantdefault | destructivedefault
insetboolean (Item, Label, SubTrigger)false

Rules

The same rules ship in DESIGN.md and the MCP server, for the agents building with this.

  • must

    Mark delete and discard items with variant="destructive" on DropdownMenuItem, and put them in their own DropdownMenuGroup after a DropdownMenuSeparator.

    destructive-item-variant

  • should

    Open an AlertDialog from a destructive item when the action can't be undone, rather than acting on the click.

    confirm-irreversible

  • must

    Put DropdownMenuLabel inside a DropdownMenuGroup; it's Base UI's group label and needs a group to belong to.

    labels-inside-groups

  • should

    Hide DropdownMenuShortcut below md (hidden md:inline) when you use it; the component doesn't do that for you.

    Shortcut hints are keyboard affordances, and the site removes those on touch screens.

    shortcuts-hidden-on-touch

  • should

    Use Select for picking a value that the trigger should then display, and keep the menu for actions and toggles.

    select-for-values

Install

bunx shadcn@latest add @cf/dropdown-menu

Needs the @cf registry in your components.json once. The theme and any sibling components come along automatically.

Source

dropdown-menu.tsxShow
"use client";
 
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
import { cn } from "cn";
import type * as React from "react";
 
function DropdownMenu(props: MenuPrimitive.Root.Props) {
  return <MenuPrimitive.Root {...props} />;
}
 
function DropdownMenuPortal(props: MenuPrimitive.Portal.Props) {
  return <MenuPrimitive.Portal {...props} />;
}
 
function DropdownMenuTrigger(props: MenuPrimitive.Trigger.Props) {
  return <MenuPrimitive.Trigger {...props} />;
}
 
function DropdownMenuContent({
  align = "start",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  className,
  ...props
}: MenuPrimitive.Popup.Props &
  Pick<
    MenuPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset"
  >) {
  return (
    <MenuPrimitive.Portal>
      <MenuPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        className="isolate z-50"
        side={side}
        sideOffset={sideOffset}
      >
        <MenuPrimitive.Popup
          className={cn(
            "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-closed:animate-out data-open:animate-in",
            className
          )}
          {...props}
        />
      </MenuPrimitive.Positioner>
    </MenuPrimitive.Portal>
  );
}
 
function DropdownMenuGroup(props: MenuPrimitive.Group.Props) {
  return <MenuPrimitive.Group {...props} />;
}
 
function DropdownMenuLabel({
  className,
  inset,
  ...props
}: MenuPrimitive.GroupLabel.Props & { inset?: boolean }) {
  return (
    <MenuPrimitive.GroupLabel
      className={cn(
        "px-2 py-1.5 font-semibold text-sm",
        inset && "pl-8",
        className
      )}
      {...props}
    />
  );
}
 
function DropdownMenuItem({
  className,
  inset,
  variant = "default",
  ...props
}: MenuPrimitive.Item.Props & {
  inset?: boolean;
  variant?: "default" | "destructive";
}) {
  return (
    <MenuPrimitive.Item
      className={cn(
        "relative flex cursor-default select-none items-center rounded-sm px-2 py-2.5 text-base outline-none transition-colors data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 md:py-1.5 md:text-sm",
        variant === "destructive" &&
          "text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive",
        inset && "pl-8",
        className
      )}
      {...props}
    />
  );
}
 
function DropdownMenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
  return <MenuPrimitive.SubmenuRoot {...props} />;
}
 
function DropdownMenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean }) {
  return (
    <MenuPrimitive.SubmenuTrigger
      className={cn(
        "flex cursor-default select-none items-center rounded-sm px-2 py-2.5 text-base outline-none data-highlighted:bg-accent data-popup-open:bg-accent md:py-1.5 md:text-sm",
        inset && "pl-8",
        className
      )}
      {...props}
    >
      {children}
      <CaretRightIcon className="ml-auto size-4" />
    </MenuPrimitive.SubmenuTrigger>
  );
}
 
function DropdownMenuSubContent({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
  return (
    <DropdownMenuContent
      className={cn("min-w-[8rem] shadow-lg", className)}
      side="right"
      sideOffset={0}
      {...props}
    />
  );
}
 
function DropdownMenuCheckboxItem({
  className,
  children,
  checked,
  ...props
}: MenuPrimitive.CheckboxItem.Props) {
  return (
    <MenuPrimitive.CheckboxItem
      checked={checked}
      className={cn(
        "relative flex cursor-default select-none items-center rounded-sm py-2.5 pr-2 pl-8 text-base outline-none transition-colors data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 md:py-1.5 md:text-sm",
        className
      )}
      {...props}
    >
      <MenuPrimitive.CheckboxItemIndicator
        render={
          // Named so an item that shows its state another way — a switch, say
          // — can hide the tick without guessing at the markup.
          <span
            className="absolute left-2 flex size-3.5 items-center justify-center"
            data-slot="menu-item-indicator"
          />
        }
      >
        <CheckIcon className="size-4" />
      </MenuPrimitive.CheckboxItemIndicator>
      {children}
    </MenuPrimitive.CheckboxItem>
  );
}
 
function DropdownMenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
  return <MenuPrimitive.RadioGroup {...props} />;
}
 
function DropdownMenuRadioItem({
  className,
  children,
  ...props
}: MenuPrimitive.RadioItem.Props) {
  return (
    <MenuPrimitive.RadioItem
      className={cn(
        "relative flex cursor-default select-none items-center rounded-sm py-2.5 pr-8 pl-2 text-base outline-none transition-colors data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 md:py-1.5 md:text-sm",
        className
      )}
      {...props}
    >
      <MenuPrimitive.RadioItemIndicator
        render={
          // On the right, where an item's state belongs: the left is where
          // every row starts, ticked or not.
          <span
            className="absolute right-2 flex size-3.5 items-center justify-center"
            data-slot="menu-item-indicator"
          />
        }
      >
        {/* A tick, not a dot: at this size a dot reads as a stray mark, and
            the answer to "which of these is it" is the same either way. */}
        <CheckIcon className="size-4" />
      </MenuPrimitive.RadioItemIndicator>
      {children}
    </MenuPrimitive.RadioItem>
  );
}
 
function DropdownMenuSeparator({
  className,
  ...props
}: MenuPrimitive.Separator.Props) {
  return (
    <MenuPrimitive.Separator
      className={cn("-mx-1 my-1 h-px bg-muted", className)}
      {...props}
    />
  );
}
 
function DropdownMenuShortcut({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
      {...props}
    />
  );
}
 
export {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
};