Context Menu
pnpm dlx shadcn@latest add …/r/context-menu.jsonAn accessible React context menu for Next.js: a right-click menu with a gliding highlight, shortcuts, checkbox and radio items and sub-menus, built on Radix.
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
export function FileActions() {
return (
<ContextMenu>
<ContextMenuTrigger className="rounded-lg border p-6" tabIndex={0}>
Right-click here
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem>
Rename
<ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}"use client";
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useState } from "react";
const sizes = [
{ value: "small", label: "Small" },
{ value: "default", label: "Default" },
{ value: "large", label: "Large" },
];
/** A page's right-click menu. Settings and choices keep it open, so you see them change. */
export default function ContextMenuDefault() {
const [bookmarks, setBookmarks] = useState(true);
const [fullUrls, setFullUrls] = useState(false);
const [size, setSize] = useState("default");
const keepOpen = (event: Event) => event.preventDefault();
return (
<ContextMenu>
{/* Focusable, so the keyboard's context-menu key or Shift+F10 opens it too. The border firms up while it's open. */}
<ContextMenuTrigger
tabIndex={0}
className="flex h-36 w-full max-w-72 items-center justify-center rounded-lg border border-dashed border-input text-sm text-muted-foreground transition-colors duration-(--duration-fast) ease-out select-none data-[state=open]:border-ring data-[state=open]:text-foreground"
>
Right-click here
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem>
Back
<ContextMenuShortcut>⌘[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>
Forward
<ContextMenuShortcut>⌘]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Reload
<ContextMenuShortcut>⌘R</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>More tools</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem>Save page as…</ContextMenuItem>
<ContextMenuItem>Create shortcut…</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>Developer tools</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuCheckboxItem checked={bookmarks} onCheckedChange={setBookmarks} onSelect={keepOpen}>
Show bookmarks bar
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={fullUrls} onCheckedChange={setFullUrls} onSelect={keepOpen}>
Show full URLs
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>Text size</ContextMenuLabel>
<ContextMenuRadioGroup value={size} onValueChange={setSize}>
{sizes.map(({ value, label }) => (
<ContextMenuRadioItem key={value} value={value} onSelect={keepOpen}>
{label}
</ContextMenuRadioItem>
))}
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add Dropdown Menu first.
Copy the source into your project.
"use client"; import { MenuCheck, MenuSubContext, MenuSurface, menuClasses, menuItemVariants, useMenuRow, useMenuSub, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { ChevronRightIcon } from "lucide-react"; import { ContextMenu as ContextMenuPrimitive, Slot } from "radix-ui"; import { type ComponentProps, useContext } from "react"; /* * Context Menu, built on Radix Context Menu: a right-click (or a long-press on touch) opens it at the pointer. * On a focused trigger, the keyboard's context-menu key and Shift+F10 open it too. Inside, the keys are * Dropdown Menu's: arrow keys, Home/End, typeahead, sub-menus on the arrow keys, Escape. * - Dropdown Menu's twin: the surface, rows, gliding highlight and marks come from dropdown-menu.tsx, so the * two menus never drift apart. Installing Context Menu brings Dropdown Menu with it. * - It grows from the pointer: Radix puts the transform origin at the corner by the pointer, and the popover * keyframes scale from there through the blur. */ function ContextMenu(props: ComponentProps<typeof ContextMenuPrimitive.Root>) { return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />; } /** The area that opens the menu. Make it focusable (tabIndex={0}) so keyboard users can open it too. */ function ContextMenuTrigger({ className, ...props }: ComponentProps<typeof ContextMenuPrimitive.Trigger>) { return ( <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" className={cn( "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", className, )} {...props} /> ); } function ContextMenuPortal(props: ComponentProps<typeof ContextMenuPrimitive.Portal>) { return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />; } function ContextMenuContent({ className, children, ...props }: ComponentProps<typeof ContextMenuPrimitive.Content>) { return ( <ContextMenuPrimitive.Portal> <ContextMenuPrimitive.Content data-slot="context-menu-content" {...props} asChild> <MenuSurface className={cn( "max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin)", className, )} > {children} </MenuSurface> </ContextMenuPrimitive.Content> </ContextMenuPrimitive.Portal> ); } function ContextMenuGroup(props: ComponentProps<typeof ContextMenuPrimitive.Group>) { return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />; } function ContextMenuLabel({ className, inset, ...props }: ComponentProps<typeof ContextMenuPrimitive.Label> & { inset?: boolean }) { return ( <ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset || undefined} className={cn(menuClasses.label, className)} {...props} /> ); } function ContextMenuItem({ className, inset, variant = "default", children, onFocus, onBlur, ...props }: ComponentProps<typeof ContextMenuPrimitive.Item> & { /** Lines the label up with items that start with an icon */ inset?: boolean; /** Destructive is red, and tints the highlight red as it lands */ variant?: "default" | "destructive"; }) { const row = useMenuRow({ tone: variant, onFocus, onBlur }); return ( <ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset || undefined} data-variant={variant} className={cn(menuItemVariants({ variant }), className)} onFocus={row.onFocus} onBlur={row.onBlur} {...props} > {row.highlight} {/* With asChild the child (a link) becomes the item and the highlight goes inside it */} <Slot.Slottable>{children}</Slot.Slottable> </ContextMenuPrimitive.Item> ); } function ContextMenuCheckboxItem({ className, children, checked, onFocus, onBlur, ...props }: ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) { const row = useMenuRow({ onFocus, onBlur }); return ( <ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" checked={checked} className={cn(menuItemVariants(), menuClasses.choice, className)} onFocus={row.onFocus} onBlur={row.onBlur} {...props} > {row.highlight} <span className={menuClasses.indicator}> <MenuCheck checked={checked} /> </span> {children} </ContextMenuPrimitive.CheckboxItem> ); } function ContextMenuRadioGroup(props: ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) { return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />; } function ContextMenuRadioItem({ className, children, onFocus, onBlur, ...props }: ComponentProps<typeof ContextMenuPrimitive.RadioItem>) { const row = useMenuRow({ onFocus, onBlur }); return ( <ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn(menuItemVariants(), menuClasses.choice, className)} onFocus={row.onFocus} onBlur={row.onBlur} {...props} > {row.highlight} <span className={menuClasses.indicator}> {/* Always mounted, so the dot can grow in and shrink away rather than appear */} <ContextMenuPrimitive.ItemIndicator forceMount className={menuClasses.dot} /> </span> {children} </ContextMenuPrimitive.RadioItem> ); } function ContextMenuSeparator({ className, ...props }: ComponentProps<typeof ContextMenuPrimitive.Separator>) { return ( <ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn(menuClasses.separator, className)} {...props} /> ); } /** A shortcut hint at the end of an item. It only shows the keys; binding them is up to you. */ function ContextMenuShortcut({ className, ...props }: ComponentProps<"span">) { return ( <span data-slot="context-menu-shortcut" className={cn(menuClasses.shortcut, className)} {...props} /> ); } function ContextMenuSub({ open, defaultOpen, onOpenChange, ...props }: ComponentProps<typeof ContextMenuPrimitive.Sub>) { const sub = useMenuSub({ open, defaultOpen, onOpenChange }); return ( <MenuSubContext.Provider value={sub.open}> <ContextMenuPrimitive.Sub data-slot="context-menu-sub" open={sub.open} onOpenChange={sub.onOpenChange} {...props} /> </MenuSubContext.Provider> ); } function ContextMenuSubTrigger({ className, inset, children, onFocus, onBlur, ...props }: ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & { inset?: boolean }) { const open = useContext(MenuSubContext); const row = useMenuRow({ keep: open, onFocus, onBlur }); return ( <ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset || undefined} className={cn(menuItemVariants(), menuClasses.subTrigger, className)} onFocus={row.onFocus} onBlur={row.onBlur} {...props} > {row.highlight} {children} <ChevronRightIcon aria-hidden className={menuClasses.chevron} /> </ContextMenuPrimitive.SubTrigger> ); } function ContextMenuSubContent({ className, sideOffset = 4, // The surface's padding and border, so the sub-menu's first item lines up with its trigger alignOffset = -5, children, ...props }: ComponentProps<typeof ContextMenuPrimitive.SubContent>) { return ( // In a portal, so the menu's own scrolling and its keyframes' transform can't clip or carry it <ContextMenuPrimitive.Portal> <ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" sideOffset={sideOffset} alignOffset={alignOffset} {...props} asChild > <MenuSurface className={cn( "max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) shadow-lg", className, )} > {children} </MenuSurface> </ContextMenuPrimitive.SubContent> </ContextMenuPrimitive.Portal> ); } export { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, };Update the import paths to match your project.
API reference
ContextMenu
The root. It opens where the pointer is, so there's no open prop. Also accepts every Radix Context Menu Root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
onOpenChange? | (open: boolean) => void | – | Called when the menu opens or closes. |
modal? | boolean | true | While open, the rest of the page can't be scrolled or clicked, and screen readers only see the menu. |
dir? | "ltr" | "rtl" | – | Reading direction. In rtl, sub-menus open to the left. |
ContextMenuTrigger
The area that opens the menu on right-click or long-press. Give it tabIndex={0} so keyboard users can open it with the context-menu key or Shift+F10.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild? | boolean | false | Use your own element, for example a card or a table row, instead of a span. |
disabled? | boolean | false | Leaves the browser's own menu. |
ContextMenuContent
The menu. It grows from the pointer through the blur, and moves to stay on screen. It scrolls when it's taller than the space left. Renders in a portal.
| Prop | Type | Default | Description |
|---|---|---|---|
alignOffset? | number | 0 | Shifts it from the pointer, in px. |
loop? | boolean | false | Arrow keys wrap from the last item to the first, and back. |
collisionPadding? | number | 0 | Space to keep from the screen's edges, in px. |
ContextMenuItem
An action. One highlight glides to the item under the pointer or the keyboard, and fades out when the pointer leaves the items.
| Prop | Type | Default | Description |
|---|---|---|---|
variant? | "default" | "destructive" | "default" | Destructive is red, and the highlight takes a red tint as it lands on it. |
inset? | boolean | false | Indents the label to line up with items that start with an icon. |
disabled? | boolean | false | Skips the item. |
onSelect? | (event: Event) => void | – | Called when the item is picked with a click, Enter or Space. The menu then closes; call event.preventDefault() to keep it open. |
textValue? | string | – | The text typeahead matches, when the item's content isn't plain text. |
ContextMenuCheckboxItem
An item that switches a setting on and off. Its check draws itself in at the end of the row.
| Prop | Type | Default | Description |
|---|---|---|---|
checked? | boolean | "indeterminate" | false | Whether it's on. Indeterminate draws a dash. |
onCheckedChange? | (checked: boolean) => void | – | Called with the new state when it's picked. |
disabled? | boolean | false | Skips the item. |
onSelect? | (event: Event) => void | – | Call event.preventDefault() to keep the menu open while switching. |
ContextMenuRadioGroup
Holds radio items, one of which is picked.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | string | – | The picked item's value. |
onValueChange? | (value: string) => void | – | Called with the new value when another item is picked. |
ContextMenuRadioItem
One choice in a radio group. Its dot grows in at the end of the row.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The value this item picks. |
disabled? | boolean | false | Skips the item. |
onSelect? | (event: Event) => void | – | Call event.preventDefault() to keep the menu open while choosing. |
ContextMenuLabel
A heading for the items below it. It isn't focusable.
| Prop | Type | Default | Description |
|---|---|---|---|
inset? | boolean | false | Indents it to line up with items that start with an icon. |
ContextMenuGroup
Groups related items (role="group").
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | The items, often after a ContextMenuLabel. |
ContextMenuSeparator
A line between groups.
| Prop | Type | Default | Description |
|---|---|---|---|
className? | string | – | Extra classes, merged with cn. |
ContextMenuShortcut
A shortcut hint at the end of an item.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | – | The keys, for example ⌘R. It only shows them: bind the keys yourself. |
ContextMenuSub
A sub-menu: wraps its trigger and its content.
| Prop | Type | Default | Description |
|---|---|---|---|
open? | boolean | – | Whether the sub-menu is open, when you control it. |
defaultOpen? | boolean | false | Opens it at first. |
onOpenChange? | (open: boolean) => void | – | Called when it opens or closes. |
ContextMenuSubTrigger
The item that opens a sub-menu, on hover, a click, Enter, Space or ArrowRight. It keeps the highlight while its sub-menu is open, and its chevron nudges towards it.
| Prop | Type | Default | Description |
|---|---|---|---|
inset? | boolean | false | Indents the label to line up with items that start with an icon. |
disabled? | boolean | false | Skips the item. |
ContextMenuSubContent
The sub-menu. It opens beside its trigger with its first item level with it, and has its own highlight. ArrowLeft goes back to the trigger; Escape closes the whole menu. Renders in a portal.
| Prop | Type | Default | Description |
|---|---|---|---|
sideOffset? | number | 4 | Gap between the menu and the sub-menu, in px. |
alignOffset? | number | -5 | Shifts it along the trigger, in px. The default lines its first item up with the trigger. |
ContextMenuPortal
Renders its children into document.body. ContextMenuContent and ContextMenuSubContent already use it.
| Prop | Type | Default | Description |
|---|---|---|---|
container? | HTMLElement | null | document.body | Where to render instead. |
Related components
An accessible React dropdown menu for Next.js with a highlight that glides between items, shortcuts, checkbox and radio items and sub-menus, built on Radix.
An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.
An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.