Bevel
Browse the docs

Menu

A selector plate: items engraved into brushed metal, the highlighted one pressed in, a lamp for checked settings, engraved rules between groups and keycaps for shortcuts. Dropdown, submenu and context menu.

Copy page

The menu on a docs page with a Markdown version: copy it, view it, or open it in ChatGPT or Claude. Pass markdownUrl.

File actions

Shortcuts as keycaps, a submenu, a setting with a lamp, a sort order and a destructive item.

quarterly-report.pdf

Preview on, sorted by modified.

Installation

Add with the CLI

  1. Once per project, add the Bevel registry to components.json.

    components.json
    {
      "registries": {
        "@bevel": "https://bevel-ui.vercel.app/r/{name}.json"
      }
    }
  2. Add Menu. This brings in the Bevel foundation, installs @base-ui/react and cn, and merges the styles into your global CSS.

    Terminal
    npx shadcn@latest add @bevel/menu
Install manually
  1. Install the dependencies.

    Terminal
    pnpm add @base-ui/react cn
  2. Add the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.

    styles/bevel.css
    /*
     * Bevel foundation. Every component builds on these tokens and utilities;
     * component stylesheets only add their own palettes and shapes.
     *
     * Layers:
     *   1. Light     one source, above and slightly in front of the screen.
     *   2. Depth     named elevations built from the light: recessed, flush,
     *                raised, pressed, object.
     *   3. Material  a surface recipe (leather, plastic, metal) that reads
     *                --bevel-material and --bevel-material-edge.
     *   4. Motion    two curves, a spring, and durations named by purpose.
     *   5. Shape     radii, stitching, and the type scale for amounts.
     */
    
    :root {
      /* 1. Light */
      --bevel-highlight: oklch(1 0 0 / 0.22);
      --bevel-highlight-strong: oklch(1 0 0 / 0.35);
      --bevel-shade: oklch(0 0 0 / 0.32);
      --bevel-shade-strong: oklch(0 0 0 / 0.5);
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
      /* Stamped into a surface: shaded on the upper inner edge, lit below. */
      --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12);
    
      /* 3. Material defaults; components and variants override these. */
      --bevel-material: oklch(0.47 0.085 52);
      --bevel-material-edge: oklch(0.3 0.06 44);
      --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */
      --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
      --bevel-grain-size: 180px;
    
      /* 2. Depth */
      --bevel-depth-recessed:
        inset 0 1px 2px var(--bevel-shade-strong),
        0 1px 0 var(--bevel-highlight);
      --bevel-depth-flush:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade);
      --bevel-depth-raised:
        inset 0 1px 0 var(--bevel-highlight),
        0 1px 0 var(--bevel-material-edge),
        0 2px 0 var(--bevel-material-edge),
        0 3px 5px var(--bevel-shade);
      --bevel-depth-pressed:
        inset 0 1px 2px var(--bevel-shade),
        0 1px 0 var(--bevel-highlight);
      /* A whole object resting on the page. */
      --bevel-depth-object:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade),
        0 0 0 1px var(--bevel-material-edge),
        0 1px 1px var(--bevel-cast),
        0 6px 12px -2px var(--bevel-cast),
        0 18px 32px -8px var(--bevel-cast);
      /* The shadow under a loose object, for filter: drop-shadow(). */
      --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast);
      /* A thin object (a card) lying on another surface. */
      --bevel-depth-sheet:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px oklch(0 0 0 / 0.12),
        0 -1px 2px oklch(0 0 0 / 0.18);
    
      /* 4. Motion */
      --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */
      --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1);
      --bevel-duration-press: 100ms; /* a control giving under a finger */
      --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */
      --bevel-duration-move: 300ms; /* objects moving: cards, drawers */
      --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */
      --bevel-stagger: 30ms;
      --bevel-lift: 3px; /* how far an object rises on hover */
      --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */
      /* Unchosen items in a group sit back in shadow. */
      --bevel-dim: 0.82;
    
      /* 5. Shape */
      --bevel-radius-xs: 0.25rem;
      --bevel-radius-sm: 0.5rem;
      --bevel-radius-md: 0.625rem;
      --bevel-radius-lg: 1rem;
      --bevel-radius-xl: 1.5rem;
      --bevel-stitch-width: 1.5px;
      --bevel-stitch-inset: 0.375rem;
      /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */
      --bevel-amount: 2.25rem;
      --bevel-amount-minor: 1.25rem;
      --bevel-label: 0.75rem;
      --bevel-fine: 0.6875rem;
      --bevel-tracking-label: 0.08em; /* stamped labels */
      --bevel-tracking-print: 0.06em; /* printed marks on cards */
    
      /* Paper and ink */
      --bevel-paper: oklch(0.975 0.008 85);
      --bevel-paper-foreground: oklch(0.28 0.02 260);
      --bevel-paper-muted: oklch(0.48 0.015 260);
      --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45);
      --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45);
      --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
      /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */
      --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E");
      --bevel-ink-red: oklch(0.52 0.19 27);
      --bevel-ink-blue: oklch(0.44 0.14 262);
      --bevel-ink-green: oklch(0.5 0.12 155);
      --bevel-ink-black: oklch(0.3 0.01 260);
      --bevel-ink-amber: oklch(0.55 0.13 65);
    
      /* Brass and ivory: instrument hardware and dial faces */
      --bevel-brass: oklch(0.76 0.11 82);
      --bevel-brass-edge: oklch(0.52 0.08 70);
      --bevel-ivory: oklch(0.95 0.025 90);
      --bevel-ivory-foreground: oklch(0.25 0.015 60);
      --bevel-needle: oklch(0.55 0.2 27);
    
      /* Desk stationery: legal pads, pens, pencils, exhibit stickers */
      --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */
      --bevel-pad-foreground: oklch(0.27 0.03 255);
      --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45);
      --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55);
      --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */
      --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */
      --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */
      --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */
      --bevel-sticker-foreground: oklch(0.26 0.03 70);
      /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */
      --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
    }
    
    .dark {
      --bevel-highlight: oklch(1 0 0 / 0.14);
      /* Paper under dim light, not inverted: it's still paper. */
      --bevel-paper: oklch(0.88 0.008 85);
      --bevel-ivory: oklch(0.86 0.022 90);
      --bevel-pad: oklch(0.86 0.07 98);
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /* Depth */
    @utility bevel-recessed {
      background-color: var(--bevel-material-recess);
      box-shadow: var(--bevel-depth-recessed);
    }
    @utility bevel-flush {
      box-shadow: var(--bevel-depth-flush);
    }
    @utility bevel-raised {
      background-color: color-mix(in oklch, var(--bevel-material), white 7%);
      background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%);
      box-shadow: var(--bevel-depth-raised);
    }
    @utility bevel-pressed {
      background-color: color-mix(in oklch, var(--bevel-material), black 4%);
      background-image: none;
      box-shadow: var(--bevel-depth-pressed);
    }
    @utility bevel-object {
      box-shadow: var(--bevel-depth-object);
    }
    
    /* Materials */
    @utility bevel-leather {
      background-color: var(--bevel-material);
      background-image:
        var(--bevel-grain),
        linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade));
      background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%;
      background-blend-mode: soft-light, normal;
    }
    @utility bevel-plastic {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%),
        linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45));
      box-shadow: var(--bevel-depth-sheet);
    }
    @utility bevel-metal {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px);
      box-shadow:
        inset 0 0 0 1px oklch(0 0 0 / 0.25),
        inset 0 1px 0 var(--bevel-highlight-strong);
    }
    
    /* Details */
    @utility bevel-deboss {
      text-shadow: var(--bevel-deboss);
    }
    @utility bevel-stitch {
      border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8));
      border-style: dashed;
      border-width: var(--bevel-stitch-width);
      filter: drop-shadow(0 1px 0 var(--bevel-shade));
    }
    /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */
    @utility bevel-fill {
      background-color: var(--bevel-fill);
      background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18));
      box-shadow: 0 0 6px -1px var(--bevel-fill);
    }
    /* A small indicator light. Colour from --bevel-led. */
    @utility bevel-led {
      background-color: var(--bevel-led);
      background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%);
      box-shadow:
        0 0 6px var(--bevel-led),
        inset 0 -1px 1px oklch(0 0 0 / 0.35),
        0 1px 0 var(--bevel-highlight);
    }
    
    /* Paper: a sheet resting on the surface, with fibre in it. */
    @utility bevel-paper {
      background-color: var(--bevel-paper);
      background-image: var(--bevel-fibre);
      background-size: 240px 240px;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
    /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */
    @utility bevel-ink {
      color: var(--bevel-ink, var(--bevel-ink-red));
      border-color: currentColor;
      mix-blend-mode: multiply;
      mask-image: var(--bevel-ink-texture);
      mask-size: 160px 160px;
    }
    /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */
    @utility bevel-glass {
      pointer-events: none;
      background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%);
    }
    
    /* Material families: one per screen. Set on the screen's container. */
    [data-bevel-family="leather"] {
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
    }
    [data-bevel-family="paper"] {
      --bevel-cast: oklch(0.3 0.01 250 / 0.16);
    }
    .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) {
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /*
     * Ruled paper: rules every --bevel-rule-height and a margin line at
     * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling
     * scrolls with the content.
     */
    @utility bevel-ruled {
      background-color: var(--bevel-sheet, var(--bevel-paper));
      background-image:
        linear-gradient(
          to right,
          transparent calc(var(--bevel-rule-margin) - 1px),
          var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px),
          transparent calc(var(--bevel-rule-margin) + 1px)
        ),
        repeating-linear-gradient(
          to bottom,
          transparent 0 calc(var(--bevel-rule-height) - 1px),
          var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height)
        ),
        var(--bevel-fibre);
      background-size: 100% 100%, 100% 100%, 240px 240px;
      background-attachment: local;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
  3. Copy menu.tsx into components/ui.

    components/menu.tsx
    "use client"
    
    import * as React from "react"
    import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
    import { Menu as MenuPrimitive } from "@base-ui/react/menu"
    import { cn } from "cn"
    
    /** A trigger and the plate it opens. */
    function Menu(props: MenuPrimitive.Root.Props) {
      return <MenuPrimitive.Root data-slot="menu" {...props} />
    }
    
    /** Opens the plate. Renders a button; pass `render` to use your own. */
    function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
      return <MenuPrimitive.Trigger data-slot="menu-trigger" {...props} />
    }
    
    const plate = cn(
      "menu-plate flex max-h-(--available-height) min-w-(--menu-min-width) origin-(--transform-origin) flex-col overflow-y-auto overscroll-contain rounded-(--bevel-radius-md) p-1 text-sm outline-none",
      "transition-[translate,opacity] duration-(--bevel-duration-small) ease-(--bevel-ease-out) motion-reduce:transition-none",
      "data-starting-style:opacity-0 data-ending-style:opacity-0",
      "data-[side=bottom]:data-starting-style:-translate-y-(--menu-lift) data-[side=top]:data-starting-style:translate-y-(--menu-lift)",
      "data-[side=right]:data-starting-style:-translate-x-(--menu-lift) data-[side=left]:data-starting-style:translate-x-(--menu-lift)",
      "forced-colors:border forced-colors:border-[CanvasText]"
    )
    
    type MenuContentProps = MenuPrimitive.Popup.Props &
      Pick<MenuPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "collisionPadding">
    
    /** The plate: brushed metal, items engraved into it. Drops a short way from the trigger. */
    function MenuContent({
      className,
      side = "bottom",
      align = "start",
      sideOffset = 6,
      alignOffset = 0,
      collisionPadding = 8,
      ...props
    }: MenuContentProps) {
      return (
        <MenuPrimitive.Portal>
          <MenuPrimitive.Positioner
            side={side}
            align={align}
            sideOffset={sideOffset}
            alignOffset={alignOffset}
            collisionPadding={collisionPadding}
            className="z-50 outline-none"
          >
            <MenuPrimitive.Popup data-slot="menu-content" className={cn(plate, className)} {...props} />
          </MenuPrimitive.Positioner>
        </MenuPrimitive.Portal>
      )
    }
    
    /* The highlighted item sits pressed into the plate. */
    const item = cn(
      "relative flex min-h-8 cursor-default items-center gap-2 rounded-(--bevel-radius-sm) px-2 py-1.5 outline-none select-none",
      "data-highlighted:bevel-pressed data-highlighted:[text-shadow:none]",
      "data-disabled:pointer-events-none data-disabled:opacity-50",
      "data-[variant=destructive]:text-(--menu-danger)",
      "forced-colors:data-highlighted:outline forced-colors:data-highlighted:outline-[Highlight]",
      "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-(--menu-ink-muted) data-[variant=destructive]:[&_svg]:text-current"
    )
    
    type MenuItemProps = MenuPrimitive.Item.Props & {
      /** "destructive" engraves the item in red, for delete and the like. */
      variant?: "default" | "destructive"
    }
    
    function MenuItem({ className, variant = "default", ...props }: MenuItemProps) {
      return <MenuPrimitive.Item data-slot="menu-item" data-variant={variant} className={cn(item, className)} {...props} />
    }
    
    /** An item that navigates: a real link, so it opens in a new tab and shows its address. */
    function MenuLinkItem({ className, ...props }: MenuPrimitive.LinkItem.Props) {
      return <MenuPrimitive.LinkItem data-slot="menu-link-item" className={cn(item, className)} {...props} />
    }
    
    /** A small lamp: lit when its item is checked. The pill is a checkbox, the round one a radio. */
    function MenuLamp({ shape }: { shape: "pill" | "round" }) {
      return (
        <span
          aria-hidden
          data-slot="menu-lamp"
          className={cn(
            "bevel-led shrink-0 forced-color-adjust-none forced-colors:border forced-colors:border-[CanvasText] in-data-checked:forced-colors:bg-[Highlight]",
            shape === "pill" ? "h-1.5 w-2.5 rounded-full" : "size-2 rounded-full"
          )}
        />
      )
    }
    
    /** A setting you turn on and off. A lamp on the left lights when it's on. */
    function MenuCheckboxItem({ className, children, ...props }: MenuPrimitive.CheckboxItem.Props) {
      return (
        <MenuPrimitive.CheckboxItem
          data-slot="menu-checkbox-item"
          closeOnClick={false}
          className={cn(item, "pl-2.5", className)}
          {...props}
        >
          <MenuLamp shape="pill" />
          {children}
        </MenuPrimitive.CheckboxItem>
      )
    }
    
    function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) {
      return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />
    }
    
    /** One of a set. The chosen one's lamp is lit. */
    function MenuRadioItem({ className, children, ...props }: MenuPrimitive.RadioItem.Props) {
      return (
        <MenuPrimitive.RadioItem
          data-slot="menu-radio-item"
          closeOnClick={false}
          className={cn(item, "pl-2.5", className)}
          {...props}
        >
          <MenuLamp shape="round" />
          {children}
        </MenuPrimitive.RadioItem>
      )
    }
    
    function MenuGroup(props: MenuPrimitive.Group.Props) {
      return <MenuPrimitive.Group data-slot="menu-group" {...props} />
    }
    
    /** A small stamped heading over a group. */
    function MenuGroupLabel({ className, ...props }: MenuPrimitive.GroupLabel.Props) {
      return (
        <MenuPrimitive.GroupLabel
          data-slot="menu-group-label"
          className={cn(
            "px-2 pt-2 pb-1 text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-label) text-(--menu-ink-muted) uppercase",
            className
          )}
          {...props}
        />
      )
    }
    
    /** An engraved rule between groups. */
    function MenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
      return <MenuPrimitive.Separator data-slot="menu-separator" className={cn("menu-rule -mx-1 my-1", className)} {...props} />
    }
    
    type MenuShortcutProps = Omit<React.ComponentProps<"span">, "children"> & {
      /** One keycap per key, e.g. ["⌘", "C"]. Also set aria-keyshortcuts on the item. */
      keys: string[]
    }
    
    /** The item's keyboard shortcut, as small keycaps at the end of the row. Hidden from screen readers. */
    function MenuShortcut({ className, keys, ...props }: MenuShortcutProps) {
      return (
        <span
          aria-hidden
          data-slot="menu-shortcut"
          className={cn("ml-auto flex shrink-0 items-center gap-0.5 pl-4 [text-shadow:none]", className)}
          {...props}
        >
          {keys.map((key, i) => (
            <kbd
              key={i}
              data-slot="menu-key"
              className="bevel-raised inline-flex h-(--menu-key-size) min-w-(--menu-key-size) -translate-y-px items-center justify-center rounded-(--bevel-radius-xs) px-1 font-sans text-(length:--bevel-fine) leading-none font-medium text-(--menu-key-foreground)"
            >
              {key}
            </kbd>
          ))}
        </span>
      )
    }
    
    /** Holds a submenu: its trigger item and its own plate. */
    function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
      return <MenuPrimitive.SubmenuRoot data-slot="menu-sub" {...props} />
    }
    
    /** The item that opens a submenu, with a chevron. Opens on hover, → or Enter. */
    function MenuSubTrigger({ className, children, ...props }: MenuPrimitive.SubmenuTrigger.Props) {
      return (
        <MenuPrimitive.SubmenuTrigger
          data-slot="menu-sub-trigger"
          className={cn(item, "data-popup-open:bevel-pressed data-popup-open:[text-shadow:none]", className)}
          {...props}
        >
          {children}
          <svg aria-hidden viewBox="0 0 16 16" className="ml-auto" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
            <path d="M6 4l4 4-4 4" />
          </svg>
        </MenuPrimitive.SubmenuTrigger>
      )
    }
    
    /** A submenu's plate. Opens beside its trigger. */
    function MenuSubContent({ side = "right", align = "start", sideOffset = 2, alignOffset = -4, ...props }: MenuContentProps) {
      return <MenuContent side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} {...props} />
    }
    
    /** Right-click (or long-press) an area to open a plate at the pointer. */
    function ContextMenu(props: ContextMenuPrimitive.Root.Props) {
      return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
    }
    
    /** The area that opens the context menu. Renders a div. */
    function ContextMenuTrigger(props: ContextMenuPrimitive.Trigger.Props) {
      return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
    }
    
    /** The context menu's plate, at the pointer. Fill it with the same Menu items. */
    function ContextMenuContent({
      className,
      ...props
    }: MenuPrimitive.Popup.Props & Pick<ContextMenuPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset">) {
      const { side, align, sideOffset, alignOffset, ...popup } = props
      return (
        <ContextMenuPrimitive.Portal>
          <ContextMenuPrimitive.Positioner
            side={side}
            align={align}
            sideOffset={sideOffset}
            alignOffset={alignOffset}
            collisionPadding={8}
            className="z-50 outline-none"
          >
            <ContextMenuPrimitive.Popup data-slot="menu-content" className={cn(plate, className)} {...popup} />
          </ContextMenuPrimitive.Positioner>
        </ContextMenuPrimitive.Portal>
      )
    }
    
    export {
      Menu,
      MenuTrigger,
      MenuContent,
      MenuItem,
      MenuLinkItem,
      MenuCheckboxItem,
      MenuRadioGroup,
      MenuRadioItem,
      MenuGroup,
      MenuGroupLabel,
      MenuSeparator,
      MenuShortcut,
      MenuSub,
      MenuSubTrigger,
      MenuSubContent,
      ContextMenu,
      ContextMenuTrigger,
      ContextMenuContent,
    }
    export type { MenuContentProps, MenuItemProps, MenuShortcutProps }
  4. Copy menu.css into your styles.

    styles/menu.css
    /*
     * Menu: a selector plate. Items are engraved into brushed metal; the
     * highlighted one sits pressed in, checked items light a lamp, separators
     * are engraved rules and shortcuts are small keycaps. Builds on bevel.css
     * (light, depth, materials, motion, shape).
     */
    
    :root {
      --menu-plate: oklch(0.91 0.004 250);
      --menu-plate-edge: oklch(0.68 0.006 250);
      --menu-rim: oklch(0 0 0 / 0.14);
      --menu-brush: oklch(1 0 0 / 0.05); /* brushing marks across the plate */
      --menu-ink: oklch(0.25 0.01 260);
      --menu-ink-muted: oklch(0.45 0.01 260);
      /* The light catches the lower lip of each engraved cut. */
      --menu-engrave: 0 1px 0 oklch(1 0 0 / 0.55);
      --menu-rule-shade: oklch(0 0 0 / 0.16);
      --menu-rule-light: oklch(1 0 0 / 0.7);
      --menu-lamp-off: oklch(0.6 0.02 150);
      --menu-lamp-on: oklch(0.74 0.19 145);
      --menu-key: oklch(0.98 0.003 90);
      --menu-key-edge: oklch(0.74 0.006 250);
      --menu-key-foreground: oklch(0.32 0.01 260);
      --menu-danger: oklch(0.5 0.19 27);
      --menu-depth:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px var(--menu-rim),
        0 1px 1px var(--bevel-cast),
        0 10px 22px -8px var(--bevel-cast);
      --menu-min-width: 12rem;
      --menu-key-size: 1.125rem;
      --menu-lift: 0.25rem;
    }
    
    .dark {
      --menu-plate: oklch(0.32 0.006 260);
      --menu-plate-edge: oklch(0.2 0.006 260);
      --menu-rim: oklch(0 0 0 / 0.4);
      --menu-ink: oklch(0.94 0.004 90);
      --menu-ink-muted: oklch(0.72 0.008 260);
      --menu-engrave: 0 -1px 0 oklch(0 0 0 / 0.5);
      --menu-rule-shade: oklch(0 0 0 / 0.45);
      --menu-rule-light: oklch(1 0 0 / 0.08);
      --menu-lamp-off: oklch(0.38 0.02 150);
      --menu-key: oklch(0.42 0.006 260);
      --menu-key-edge: oklch(0.22 0.006 260);
      --menu-key-foreground: oklch(0.92 0.004 90);
      --menu-danger: oklch(0.72 0.17 27);
    }
    
    [data-slot="menu-content"] {
      --bevel-material: var(--menu-plate);
      --bevel-material-edge: var(--menu-plate-edge);
    }
    [data-slot="menu-shortcut"] {
      --bevel-material: var(--menu-key);
      --bevel-material-edge: var(--menu-key-edge);
    }
    [data-slot="menu-lamp"] {
      --bevel-led: var(--menu-lamp-off);
    }
    [data-checked] > [data-slot="menu-lamp"] {
      --bevel-led: var(--menu-lamp-on);
    }
    
    /* Brushed metal plate, lifted off the page. */
    @utility menu-plate {
      background-color: var(--menu-plate);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight), transparent 40%, var(--bevel-shade) 140%),
        repeating-linear-gradient(90deg, var(--menu-brush) 0 1px, transparent 1px 3px);
      box-shadow: var(--menu-depth);
      color: var(--menu-ink);
      text-shadow: var(--menu-engrave);
    }
    
    /* An engraved rule: a shaded cut with a lit lip below it. */
    @utility menu-rule {
      border-top: 1px solid var(--menu-rule-shade);
      border-bottom: 1px solid var(--menu-rule-light);
    }
  5. Import the stylesheets after Tailwind in your global CSS, foundation first.

    globals.css
    @import "./bevel.css";
    @import "./menu.css";

Anatomy

Each part is its own component. Leave out the ones you don't need.

Anatomy
<Menu>
  <MenuTrigger />
  <MenuContent>
    <MenuItem>
      Duplicate
      <MenuShortcut keys={["⌘", "D"]} />
    </MenuItem>
    <MenuLinkItem href="/page.md" />
    <MenuSub>
      <MenuSubTrigger />
      <MenuSubContent>
        <MenuItem />
      </MenuSubContent>
    </MenuSub>
    <MenuSeparator />
    <MenuCheckboxItem />
    <MenuGroup>
      <MenuGroupLabel />
      <MenuRadioGroup>
        <MenuRadioItem />
      </MenuRadioGroup>
    </MenuGroup>
  </MenuContent>
</Menu>

Parts

PartWhat it does
MenuMenu rootopen, defaultOpen, onOpenChange, modal.
MenuTriggerbuttonOpens the plate. Pass render to use your own button.
MenuContentMenu popupThe plate. side (default bottom), align (default start), sideOffset, alignOffset, collisionPadding. Scrolls when tall.
MenuItemdiv (menuitem)Pressed in while highlighted. onClick runs it. variant="destructive" engraves it in red. Icons are muted and sized for you.
MenuLinkItema (menuitem)An item that is a real link: href, target.
MenuCheckboxItemdiv (menuitemcheckbox)A pill lamp, lit when checked. checked, onCheckedChange. Stays open when toggled.
MenuRadioGroupdiv (group)value, onValueChange.
MenuRadioItemdiv (menuitemradio)A round lamp, lit when chosen. value.
MenuGroupdiv (group)Groups items under a label.
MenuGroupLabeldivA small stamped heading.
MenuSeparatordiv (separator)An engraved rule.
MenuShortcutspankeys: one keycap per key. Hidden from screen readers; put aria-keyshortcuts on the item.
MenuSubSubmenu rootHolds a submenu.
MenuSubTriggerdiv (menuitem)Opens the submenu on hover, → or Enter. Has a chevron.
MenuSubContentMenu popupThe submenu's plate, beside its trigger.
ContextMenuContext menu rootOpens on right-click or long-press.
ContextMenuTriggerdivThe area that opens it.
ContextMenuContentMenu popupThe plate at the pointer. Fill it with the same items.