Skip to content
ssntl/ui

Menu

A dropdown menu on elevation 2 whose inset header and items share the panel's curve.

Installation

$ pnpm dlx shadcn@latest add @ssntl/menu

Usage

tsx
import { Menu, MenuContent, MenuHeader, MenuItem, MenuSeparator, MenuTrigger } from "@/components/ui/menu"

<Menu>
  <MenuTrigger asChild><Button variant="secondary">Product actions</Button></MenuTrigger>
  <MenuContent>
    <MenuHeader>Ceramic cup</MenuHeader>
    <MenuItem><Pencil /> Edit</MenuItem>
    <MenuSeparator />
    <MenuItem destructive><Trash2 /> Delete</MenuItem>
  </MenuContent>
</Menu>

Rules

What this component expects from you, as it ships in components/ui/menu.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

components/ui/menu.md
## Rules for Menu

- **Dropdown + inset header.** The panel is `radius-md` (22) with 8px of padding, so the header and items take 14 (`radius-sm`) and nest in its curve.
- **Dropdown layer.** Elevation-2, entering with `pop-in` from the trigger's side.
- Radix handles arrow keys, typeahead, Escape and focus return. Items are 44px.
- Destructive items are red text, last, after a separator; confirm in a Dialog before they run.
- Only show a shortcut that works.
Open the full rules file (opens in a new tab)

Code

components/ui/menu.tsx
"use client"

import * as React from "react"
import { DropdownMenu as MenuPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

// A dropdown menu (Radix): keyboard, typeahead, focus return and collision
// handling come from the primitive. Elevation 2: it floats above the page.
// Nested corners: the panel is radius-md (22) with 8px of padding, so the
// inset header and every item take 22 − 8 = 14 (radius-sm) and share its
// curve.
const Menu = MenuPrimitive.Root
const MenuTrigger = MenuPrimitive.Trigger
const MenuGroup = MenuPrimitive.Group

function MenuContent({
  className,
  sideOffset = 8,
  align = "start",
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Content>) {
  return (
    <MenuPrimitive.Portal>
      <MenuPrimitive.Content
        data-slot="menu-content"
        sideOffset={sideOffset}
        align={align}
        className={cn(
          "z-50 grid max-h-(--radix-dropdown-menu-content-available-height) min-w-56 origin-(--radix-dropdown-menu-content-transform-origin) gap-0.5 overflow-y-auto overscroll-contain rounded-outer-md bg-popover p-concentric-2 text-popover-foreground elevation-2",
          "data-[state=closed]:pop-out data-[state=open]:pop-in",
          className
        )}
        {...props}
      />
    </MenuPrimitive.Portal>
  )
}

/** A muted card at the top: who or what the menu acts on. */
function MenuHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="menu-header"
      className={cn(
        "mb-1 grid gap-0.5 rounded-inner bg-muted px-3 py-2.5",
        className
      )}
      {...props}
    />
  )
}

function MenuItem({
  className,
  destructive = false,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Item> & {
  /** Red text for Delete or Remove; confirm before it runs. */
  destructive?: boolean
}) {
  return (
    <MenuPrimitive.Item
      data-slot="menu-item"
      className={cn(
        "flex min-h-11 cursor-pointer items-center gap-3 rounded-inner px-3 text-label font-normal outline-none select-none",
        "data-disabled:pointer-events-none data-disabled:opacity-40 data-highlighted:bg-secondary",
        "[&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:opacity-80",
        destructive && "text-destructive",
        className
      )}
      {...props}
    />
  )
}

function MenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof MenuPrimitive.Separator>) {
  return (
    <MenuPrimitive.Separator
      data-slot="menu-separator"
      className={cn("mx-3 my-1 h-px bg-border", className)}
      {...props}
    />
  )
}

/** A keyboard shortcut at the end of an item. */
function MenuShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
  return (
    <kbd
      data-slot="menu-shortcut"
      className={cn(
        "ms-auto font-sans text-caption text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

export {
  Menu,
  MenuContent,
  MenuGroup,
  MenuHeader,
  MenuItem,
  MenuSeparator,
  MenuShortcut,
  MenuTrigger,
}