Context Menu
The macOS 26 right-click menu — frosted glass panel, 13pt rows, submenus, checkboxes, shortcuts.
A control- / right-click menu rendered on the same frosted Popover material as
Combobox and Select — a translucent --acr-panel pane over a backdrop blur. The
row geometry is lifted from the kit's 13pt Menu: 13px SF-Medium labels, a 6px-radius
highlight bar, a section header, a hairline separator, a muted keyboard-shortcut
column, submenus with a trailing chevron, and a red destructive row. Built on Radix
Context Menu, so it's fully keyboard-navigable.
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/context-menu.jsonUsage
"use client"import * as React from "react"import { ArrowLeft, ArrowRight, RotateCw, Trash2 } from "lucide-react"import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger,} from "@/registry/acrylic/context-menu"import { ExampleBackdrop } from "@/components/example-backdrop"export default function ContextMenuDemo() { return ( <ExampleBackdrop> <ContextMenu> <ContextMenuTrigger className="flex h-36 w-72 select-none items-center justify-center rounded-[12px] border border-dashed border-border bg-[var(--acr-chip)] text-[13px] text-muted-foreground backdrop-blur-sm"> Right-click here </ContextMenuTrigger> <ContextMenuContent className="w-52"> <ContextMenuItem> <ArrowLeft /> Back <ContextMenuShortcut>⌘[</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem> <ArrowRight /> Forward <ContextMenuShortcut>⌘]</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem> <RotateCw /> Reload <ContextMenuShortcut>⌘R</ContextMenuShortcut> </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem variant="destructive"> <Trash2 /> Delete <ContextMenuShortcut>⌫</ContextMenuShortcut> </ContextMenuItem> </ContextMenuContent> </ContextMenu> </ExampleBackdrop> )}import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/acrylic/context-menu"
<ContextMenu>
<ContextMenuTrigger>Right-click here</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
Back
<ContextMenuShortcut>⌘[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>Example — Finder desktop menu
A 1:1 of the kit's Menus / Examples / 13pt artboard. Left is the menu in its full open form (including the open submenu, with the accent-highlighted leaf); right is the same menu live — right-click to open it and hover into Clean Up By.
"use client"import * as React from "react"import { ChevronRight, Folder, Info, Image as ImageIcon, Smartphone, type LucideIcon } from "lucide-react"import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, contextMenuItemVariants, contextMenuSurfaceClass,} from "@/registry/acrylic/context-menu"import { ExampleBackdrop } from "@/components/example-backdrop"import { cn } from "@/lib/utils"// 1:1 of the kit's "Menus / Examples / 13pt" — a Finder desktop menu.//// The menu is defined ONCE as `FINDER`. Both panes render from it:// • right pane = the live <ContextMenu> (open it on right-click)// • left pane = the same items as a still "full form"// Radix can't render a contained, always-open ContextMenu (it positions in the// viewport and has no declarative `open`), so the left pane re-renders the items —// from the SAME data, reusing the component's own surface/row classes. The open// submenu is rendered as a SIBLING layer (not nested inside the panel) so it// composites like the live portal — otherwise two nested translucent panels let the// main menu show through the submenu.type MenuItem = { icon?: LucideIcon; label: string; inset?: boolean; sub?: string[] }type MenuEntry = { sep: true } | { header: string } | MenuItemconst FINDER: MenuEntry[] = [ { icon: Folder, label: "New Folder" }, { sep: true }, { icon: Info, label: "Get Info" }, { icon: ImageIcon, label: "Change Wallpaper…" }, { sep: true }, { header: "Desktop Stacks" }, { label: "Use Stacks", inset: true }, { label: "Clean Up By", inset: true, sub: ["Name", "Kind", "Date Modified", "Date Created", "Size", "Tags"] }, { sep: true }, { icon: Smartphone, label: "Import from iPhone" },]// ----- live menu (the real component) -----function LiveMenu({ items }: { items: MenuEntry[] }) { return ( <ContextMenuContent className="w-[230px]"> {items.map((e, i) => { if ("sep" in e) return <ContextMenuSeparator key={i} /> if ("header" in e) return <ContextMenuLabel key={i}>{e.header}</ContextMenuLabel> const Icon = e.icon if (e.sub) return ( <ContextMenuSub key={i}> <ContextMenuSubTrigger inset={e.inset}> {Icon && <Icon />} {e.label} </ContextMenuSubTrigger> <ContextMenuSubContent className="w-[150px]"> {e.sub.map((s) => ( <ContextMenuItem key={s}>{s}</ContextMenuItem> ))} </ContextMenuSubContent> </ContextMenuSub> ) return ( <ContextMenuItem key={i} inset={e.inset}> {Icon && <Icon />} {e.label} </ContextMenuItem> ) })} </ContextMenuContent> )}// ----- static "full form": same data, same classes, plain elements -----const surface = contextMenuSurfaceClassconst row = contextMenuItemVariants({ size: "default" })const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffectfunction StaticMenu({ items }: { items: MenuEntry[] }) { const wrapRef = React.useRef<HTMLDivElement>(null) const subTriggerRef = React.useRef<HTMLDivElement>(null) const [subTop, setSubTop] = React.useState(0) const subEntry = items.find((e): e is MenuItem => "sub" in e && !!e.sub) // Anchor the open submenu to the "Clean Up By" row by measuring it, so "Name" // lines up with the trigger without a magic offset. useIsoLayoutEffect(() => { if (wrapRef.current && subTriggerRef.current) { const r = subTriggerRef.current.getBoundingClientRect() const w = wrapRef.current.getBoundingClientRect() setSubTop(r.top - w.top) } }, []) return ( // wrapper reserves room for the submenu (panel 230 + sub 150 − 7 overlap) <div ref={wrapRef} className="relative w-[373px]"> <div className={cn(surface, "w-[230px]")}> {items.map((e, i) => { if ("sep" in e) return <div key={i} className="-mx-1 my-1 h-px bg-black/10 dark:bg-white/10" /> if ("header" in e) return ( <div key={i} className="px-2 pb-1 pt-1.5 text-[11px] font-semibold text-muted-foreground"> {e.header} </div> ) const Icon = e.icon if (e.sub) return ( <div key={i} ref={subTriggerRef} className={cn(row, "bg-[var(--acr-chip)]", e.inset && "pl-8")}> {Icon && <Icon />} {e.label} <ChevronRight className="ml-auto !size-3 opacity-60" /> </div> ) return ( <div key={i} className={cn(row, e.inset && "pl-8")}> {Icon && <Icon />} {e.label} </div> ) })} </div> {/* open submenu — a sibling layer (like the live portal), 7px overlap. "Name" is the accent-highlighted leaf. */} {subEntry?.sub && ( <div className={cn(surface, "absolute left-[223px] w-[150px]")} style={{ top: subTop }}> {subEntry.sub.map((s, j) => ( <div key={s} className={cn(row, j === 0 && "bg-[var(--primary)] text-primary-foreground")}> {s} </div> ))} </div> )} </div> )}function Caption({ children }: { children: React.ReactNode }) { return <span className="text-[11px] font-medium text-muted-foreground">{children}</span>}export default function ContextMenuFinder() { return ( <ExampleBackdrop className="items-start justify-center gap-10 pt-20 min-h-[34rem]"> <div className="flex flex-col gap-2"> <Caption>Full form</Caption> <StaticMenu items={FINDER} /> </div> <div className="flex flex-col gap-2"> <Caption>Right-click ↓</Caption> <ContextMenu> <ContextMenuTrigger asChild> <div className="flex h-44 w-64 select-none items-center justify-center rounded-[12px] border border-dashed border-border bg-[var(--acr-chip)] text-[13px] text-muted-foreground backdrop-blur-sm"> Right-click the desktop </div> </ContextMenuTrigger> <LiveMenu items={FINDER} /> </ContextMenu> </div> </ExampleBackdrop> )}Example — full vocabulary
Section header, checkbox + radio groups, a submenu, a shortcut column, and a destructive row — the complete vocabulary on the frosted panel.
"use client"import * as React from "react"import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,} from "@/registry/acrylic/context-menu"import { ExampleBackdrop } from "@/components/example-backdrop"// Full menu vocabulary: section header, checkbox + radio groups, a submenu, a// shortcut column, a separator, and a destructive row — all on the frosted panel.export default function ContextMenuShowcase() { const [bookmarks, setBookmarks] = React.useState(true) const [fullUrls, setFullUrls] = React.useState(false) const [pane, setPane] = React.useState("pedro") return ( <ExampleBackdrop> <ContextMenu> <ContextMenuTrigger className="flex h-44 w-80 select-none items-center justify-center rounded-[12px] border border-dashed border-border bg-[var(--acr-chip)] text-[13px] text-muted-foreground backdrop-blur-sm"> Right-click for the full menu </ContextMenuTrigger> <ContextMenuContent className="w-60"> <ContextMenuLabel>Page</ContextMenuLabel> <ContextMenuItem inset> Back <ContextMenuShortcut>⌘[</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem inset disabled> Forward <ContextMenuShortcut>⌘]</ContextMenuShortcut> </ContextMenuItem> <ContextMenuSub> <ContextMenuSubTrigger inset>More tools</ContextMenuSubTrigger> <ContextMenuSubContent className="w-48"> <ContextMenuItem> Save page… <ContextMenuShortcut>⌘S</ContextMenuShortcut> </ContextMenuItem> <ContextMenuItem>Create shortcut…</ContextMenuItem> <ContextMenuItem>Name window…</ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem variant="destructive">Clear data</ContextMenuItem> </ContextMenuSubContent> </ContextMenuSub> <ContextMenuSeparator /> <ContextMenuCheckboxItem checked={bookmarks} onCheckedChange={setBookmarks}> Show Bookmarks <ContextMenuShortcut>⌘⇧B</ContextMenuShortcut> </ContextMenuCheckboxItem> <ContextMenuCheckboxItem checked={fullUrls} onCheckedChange={setFullUrls}> Show Full URLs </ContextMenuCheckboxItem> <ContextMenuSeparator /> <ContextMenuLabel>People</ContextMenuLabel> <ContextMenuRadioGroup value={pane} onValueChange={setPane}> <ContextMenuRadioItem value="pedro">Pedro Duarte</ContextMenuRadioItem> <ContextMenuRadioItem value="colm">Colm Tuite</ContextMenuRadioItem> </ContextMenuRadioGroup> </ContextMenuContent> </ContextMenu> </ExampleBackdrop> )}Sizes
The kit ships three menus — 13pt (default), 11pt (sm), 10pt (xs).
The number is the text point size; the row height scales with it (24 / 22 / 19px).
Set size once on ContextMenuContent — it flows through to every row and
submenu, so the whole menu scales without sizing each item. default is the standard
system menu. (The section header stays 10pt across sizes, matching the kit.)
"use client"import * as React from "react"import { Copy, Scissors, Trash2 } from "lucide-react"import { contextMenuItemVariants, contextMenuSurfaceClass, type ContextMenuSize,} from "@/registry/acrylic/context-menu"import { Badge } from "@/registry/acrylic/badge"import { ExampleBackdrop } from "@/components/example-backdrop"import { cn } from "@/lib/utils"// The three kit menu sizes, set once via <ContextMenuContent size="...">. Shown as// still panels (a live ContextMenu can't render open), each built from the SAME// `contextMenuItemVariants(size)` the real component uses — so what you see is what// the prop produces.const SIZES: { size: ContextMenuSize; label: string }[] = [ { size: "default", label: "default · 13pt" }, { size: "sm", label: "sm · 11pt" }, { size: "xs", label: "xs · 10pt" },]function SizedMenu({ size }: { size: ContextMenuSize }) { const row = contextMenuItemVariants({ size }) return ( <div className={cn(contextMenuSurfaceClass, "w-[180px]")}> <div className="px-2 pb-1 pt-1.5 text-[11px] font-semibold text-muted-foreground">Edit</div> <div className={cn(row, "bg-[var(--primary)] text-primary-foreground")}> <Copy /> Copy <span className="ml-auto pl-4 tracking-wide text-primary-foreground/80">⌘C</span> </div> <div className={row}> <Scissors /> Cut <span className="ml-auto pl-4 tracking-wide text-[var(--label-tertiary)]">⌘X</span> </div> <div className="-mx-1 my-1 h-px bg-black/10 dark:bg-white/10" /> <div className={cn(row, "text-destructive")}> <Trash2 /> Delete </div> </div> )}export default function ContextMenuSizes() { return ( <ExampleBackdrop className="!flex-row flex-wrap items-start justify-center gap-6"> {SIZES.map(({ size, label }) => ( <div key={size} className="flex flex-col items-center gap-2.5"> <Badge variant="secondary">{label}</Badge> <SizedMenu size={size} /> </div> ))} </ExampleBackdrop> )}<ContextMenuContent size="sm">
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Cut</ContextMenuItem>
</ContextMenuContent>Anatomy
| part | role |
|---|---|
ContextMenuTrigger | the area that opens the menu on right- / control-click |
ContextMenuContent | the frosted panel |
ContextMenuItem | a row; inset reserves the leading icon column, variant="destructive" makes it red |
ContextMenuCheckboxItem | a row with a leading checkmark indicator |
ContextMenuRadioGroup / ContextMenuRadioItem | single-choice rows with a dot indicator |
ContextMenuLabel | a section header (kit's 10pt gray label); inset aligns it to the rows |
ContextMenuSeparator | a hairline divider |
ContextMenuShortcut | the right-aligned, muted keyboard-shortcut column |
ContextMenuSub / ContextMenuSubTrigger / ContextMenuSubContent | a nested submenu with a trailing chevron |
Notes
- The panel uses the shared frosted material (
--acr-panel+backdrop-blur-xl), identical to Combobox / Select / Popover — overlay it on busy content to read as glass. - The highlight bar is System Blue + white text (the macOS accent), as in the kit's rendered menu. A submenu trigger whose child is open drops to a neutral gray, so focus reads as having moved into the child.
- All colors resolve through the Acrylic theme tokens, so the menu flips light / dark with the theme.
sizeis set onContextMenuContentand propagates via React context — see Sizes.