Context Menu React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React context menu component: actions on right-click for grids and lists, and an iOS-style action sheet on a long press on touch screens.
Right-click a tile and the menu grows out of the pointer on a quick spring. Hover or use the arrow keys to move the highlight, type a letter to jump, Right opens a submenu and Left closes it, Enter or Space picks, Escape closes. The shortcuts written next to items run them while the menu is open. Keyboard users reach the same menu from a focused tile with Shift+F10 or the Menu key. On a phone or a touch screen, press and hold a tile: it dips slightly while you hold, a haptic tick fires, and an action sheet slides up with the tile's name on top, 48px rows, submenus as pages with a Back row and a Cancel button at the thumb. Scrolling never opens it, and text selection with a mouse still works. With reduced motion the tile does not dip and the menu and sheet fade.
Context Menu is a Pro React component in the React overlay components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add context-menu, together with Dropdown Menu, Motion presets, useLongPress, useMediaQuery, useShortcut and 1 more and the npm packages @radix-ui/react-context-menu and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add context-menuContext Menu is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import ContextMenu from @/components/ui/context-menu.
import { Copy, ExternalLink, Heart, Trash2 } from "lucide-react"
import { ContextMenu, type ContextMenuEntry } from "@/components/ui/context-menu"
export function SavedListing({ favorite, onFavorite, onDelete }: { favorite: boolean; onFavorite: (next: boolean) => void; onDelete: () => void }) {
const items: ContextMenuEntry[] = [
{ label: "Open", icon: <ExternalLink />, href: "/listings/park-slope-apartment" },
{ label: "Copy link", icon: <Copy />, shortcut: "mod+c", onSelect: () => navigator.clipboard.writeText("https://listings.example.com/a/123") },
{ type: "checkbox", label: "Favorite", icon: <Heart />, checked: favorite, onCheckedChange: onFavorite },
{ type: "separator" },
{ label: "Delete", icon: <Trash2 />, tone: "danger", onSelect: onDelete },
]
return (
<ContextMenu items={items} title="2-bedroom apartment, Park Slope" description="$489,000 · New York" cancelLabel="Cancel" backLabel="Back">
{/* focusable, so Shift+F10 opens the menu from the keyboard */}
<a href="/listings/park-slope-apartment" className="block rounded-2xl bg-surface p-3 shadow-raised">
2-bedroom apartment, Park Slope
</a>
</ContextMenu>
)
}Props
childrenReactNodeThe target. A single element (a card link, a row button) takes the handlers itself; anything else is wrapped in a <span>. Make it focusable so keyboard users can open the menu with Shift+F10.
itemsMenuEntry[]The same entries as the dropdown menu: items ({ label, icon, description, shortcut, badge, onSelect, href, tone: "danger", keepOpen }), { type: "checkbox" }, { type: "radio" }, { type: "submenu", items }, { type: "group" }, { type: "label" } and { type: "separator" }. With no enabled entry the browser's own menu shows.
titledefault""ReactNodeThe action sheet's centered header on phones, usually the name of what was pressed. Not shown on desktop. The demo uses each tile's title while this is empty.
descriptiondefault""ReactNodeA quieter line under the sheet title. The demo shows each tile's price and city while this is empty.
sheetTitledefault"Actions"stringThe sheet's title for screen readers when there is no visible title.
showCanceldefaulttruebooleanThe sheet's Cancel button at the bottom. The sheet still closes on a drag, a tap outside or Escape.
cancelLabeldefault"Cancel"stringThe label of the sheet's Cancel button.
backLabeldefault"Back"stringThe back row at the top of a submenu page in the sheet.
sizedefault"md""sm" | "md"Item height 32 / 36px with 13 / 14px text. 44px on touch either way; sheet rows are always 48px.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Panel corners 0 / 12 / 16 / 24px (full falls back to lg); items keep the panel radius minus its 6px padding.
minWidthdefault192numberThe panel's minimum width in px. It grows for longer labels up to 384px, then labels truncate.
pressFeedbackdefaulttruebooleanWhile a finger holds the target it dips to 0.97 on a snappy spring (iOS), and springs back when the sheet opens or the press is cancelled. Off under reduced motion.
disableddefaultfalsebooleanThe browser's own menu shows instead, and a long press does nothing.
responsivedefaulttruebooleanBelow 768px and on touch screens a long press (or a right-click) opens the action sheet. Off leaves Radix's own touch long press, which opens the floating menu at the finger.
longPressDelaydefault450numberHow long a finger holds before the sheet opens, in ms (gestures.longPress). Moving more than 8px cancels, so scrolling never opens it.
loopdefaulttruebooleanArrow keys wrap from the last item to the first (menu and sheet).
modaldefaulttruebooleanWhile the floating menu is open, the page behind ignores the pointer and does not scroll (Radix). The sheet is always modal.
onOpenChange(open: boolean) => voidCalled when the menu or the sheet opens or closes.
containerHTMLElement | nullPortal target for the menu and the sheet (default document.body).
classNamesPartial<Record<"content" | "item" | "icon" | "label" | "description" | "shortcut" | "separator" | "sheet", string>>Extra classes for the parts: the panel (and sub panels), every row, row icons, labels, descriptions, shortcuts, separators and the sheet.
classNamestringExtra classes for the target; any other prop (aria-*, data-*, handlers) is passed to it too. It carries data-state (open / closed) and, in sheet mode, data-pressing while a long press is held. To keep the browser menu on a link inside the target, stop it there: onContextMenu={(e) => e.stopPropagation()}.
Parts
ContextMenuRoot
componentCompound API: decides the mode once (the floating Radix menu, or the action sheet on phones and touch screens) and every part follows it. There is no open prop: a context menu opens at a pointer. Props: { onOpenChange, modal, responsive, disabled, longPressDelay, pressFeedback, container, dir, children }.
ContextMenuTrigger
componentThe target area (context-menu-trigger). One element child is used as the target (asChild by default); otherwise a <span>. Props: { asChild, disabled } & ComponentProps<"span">.
ContextMenuContent
componentThe panel (context-menu-content) with the popover spring from the pointer, or the action sheet. JSX children (the parts below) are desktop-only Radix rows, so a content with children keeps the floating menu everywhere, as if responsive were false. Props: { items, size, radius, minWidth, loop, title, description, sheetTitle, showCancel, cancelLabel, backLabel, container, classNames } & Radix ContextMenu.Content props.
ContextMenuItem
componentA row. href renders an <a>; asChild takes a router Link and keeps the row layout. Its shortcut runs it while the menu is open. Props: { icon, description, shortcut, badge, tone: "neutral" | "danger", keepOpen, href, target, rel, asChild, disabled, onSelect }.
ContextMenuCheckboxItem
componentA row with a leading checkbox whose check draws itself; it stays open while toggling. Props: { checked, defaultChecked, onCheckedChange, icon, description, shortcut, keepOpen = true, disabled }.
ContextMenuRadioGroup
componentHolds one choice from a set of radio items. Props: { value, defaultValue, onValueChange }.
ContextMenuRadioItem
componentOne choice in a radio group, marked with a trailing check. Props: { value, icon, description, keepOpen = true, disabled }.
ContextMenuSub
componentA nested menu: holds the open state of a side panel. Props: { open, defaultOpen, onOpenChange }.
ContextMenuSubTrigger
componentThe row with a chevron that opens the nested menu. Props: { icon, description, badge, disabled }.
ContextMenuSubContent
componentThe side panel of a nested menu. Props: { items, sideOffset, alignOffset, container }.
ContextMenuGroup
componentGroups related items into a section. Takes Radix Group props.
ContextMenuLabel
componentThe small muted label of a section. Takes Radix Label props.
ContextMenuSeparator
componentA hairline between sections. Takes Radix Separator props.
ContextMenuEntry
typeOne entry of items: an action, checkbox, radio group, submenu, label or separator, shared with the dropdown menu.
Examples
File list with a selection
Right-click inside a selection and the actions count it (“Delete 3 files”); outside it, only that row is selected.
Chat messages
Reply, copy, forward and delete on each bubble; a long press on a phone opens the sheet with the message on top.
Press feedback on and off
On touch the target dips to 0.97 while held, on a snappy spring; pressFeedback={false} keeps it still.
Disabled
disabled hands the right-click back to the browser's own menu.
Parts with custom JSX
ContextMenuRoot, Trigger and Content with JSX rows. Custom parts are desktop-only: this menu stays a floating panel on phones.
Dependencies
Files
- components/ui/context-menu.tsx
FAQ
Is the React Context Menu component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Context Menu?
Run npx wingo-ui@latest add context-menu. It also adds the Wingo UI items it builds on (Dropdown Menu, Motion presets, useLongPress, useMediaQuery, useShortcut and 1 more) and the npm packages @radix-ui/react-context-menu and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/context-menu.
How does Context Menu behave on phones?
Below 768px and on touch screens a long press (or a right-click) opens the action sheet. Off leaves Radix's own touch long press, which opens the floating menu at the finger. The responsive prop controls this and is on by default.