A right-click menu of actions, with submenus, keyboard shortcut hints, disabled items and destructive actions.
Right-click the canvas
pnpm dlx shadcn@latest add @latheui/context-menuFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react radix-ui motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { ChevronRightIcon } from "lucide-react"
import { ContextMenu as Primitive } from "radix-ui"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { fade, spring, useMotionSafe } from "@/lib/motion"
import { Kbd } from "@/components/ui/kbd"
const OpenContext = React.createContext(false)
export function ContextMenu({ onOpenChange, ...props }: React.ComponentProps<typeof Primitive.Root>) {
const [open, setOpen] = React.useState(false)
return <OpenContext.Provider value={open}>
<Primitive.Root {...props} onOpenChange={next => { setOpen(next); onOpenChange?.(next) }} />
</OpenContext.Provider>
}
export function ContextMenuTrigger({
onKeyDown,
disabled,
...props
}: React.ComponentProps<typeof Primitive.Trigger>) {
return (
<Primitive.Trigger
{...{ asChild: true }}
disabled={disabled}
{...props}
onKeyDown={(event) => {
onKeyDown?.(event)
if (disabled || event.defaultPrevented || event.nativeEvent.isComposing)
return
if (
event.key === "ContextMenu" ||
(event.shiftKey && event.key === "F10")
) {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
event.currentTarget.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: rect.left + 8,
clientY: rect.bottom,
})
)
}
}}
/>
)
}
export function ContextMenuContent({
className,
children,
...props
}: React.ComponentProps<typeof Primitive.Content>) {
const open = React.useContext(OpenContext)
const { reduced, safe } = useMotionSafe()
return (
<AnimatePresence>
{open && <Primitive.Portal forceMount>
<Primitive.Content {...{ asChild: true }} forceMount {...props}>
<motion.div data-slot="context-menu-content" inert={!open || undefined}
initial={{ opacity: 0, scale: reduced ? 1 : 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.97, transition: fade }}
transition={safe(spring.snappy)}
className={cn("z-50 min-w-44 origin-(--radix-context-menu-content-transform-origin) rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none", className)}>
{children}
</motion.div>
</Primitive.Content>
</Primitive.Portal>}
</AnimatePresence>
)
}
export type ContextMenuItemProps = React.ComponentProps<
typeof Primitive.Item
> & {
destructive?: boolean
/** Keyboard shortcut hint, shown right-aligned, for example `"mod+d"`. Display only. */
shortcut?: string | string[]
}
export function ContextMenuItem({
className,
destructive,
shortcut,
children,
...props
}: ContextMenuItemProps) {
return (
<Primitive.Item
className={cn(
itemClass,
destructive &&
"text-destructive data-highlighted:bg-destructive/10 data-highlighted:text-destructive [&_svg]:text-destructive",
className
)}
{...props}
>
{children}
{shortcut && <Kbd keys={shortcut} variant="ghost" className="ml-auto pl-4" />}
</Primitive.Item>
)
}
const itemClass =
"relative flex h-8 cursor-default items-center gap-2 rounded-md px-2 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
const SubOpenContext = React.createContext(false)
/**
* A nested menu. Moving the pointer diagonally from its trigger toward it keeps it open, even across
* other items: Radix tracks a safe area between the pointer and the submenu.
*/
export function ContextMenuSub({
open: openProp,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof Primitive.Sub>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
const open = openProp ?? uncontrolled
return (
<SubOpenContext.Provider value={open}>
<Primitive.Sub
open={open}
onOpenChange={(next) => {
if (openProp === undefined) setUncontrolled(next)
onOpenChange?.(next)
}}
{...props}
/>
</SubOpenContext.Provider>
)
}
export function ContextMenuSubTrigger({
className,
children,
...props
}: React.ComponentProps<typeof Primitive.SubTrigger>) {
return (
<Primitive.SubTrigger className={cn(itemClass, "data-[state=open]:bg-accent", className)} {...props}>
{children}
<ChevronRightIcon aria-hidden="true" className="ml-auto size-3.5!" />
</Primitive.SubTrigger>
)
}
export function ContextMenuSubContent({
className,
children,
sideOffset = 4,
// Lines the submenu's first item up with its trigger: the content's border and padding.
alignOffset = -5,
...props
}: React.ComponentProps<typeof Primitive.SubContent>) {
const open = React.useContext(SubOpenContext)
const { reduced, safe } = useMotionSafe()
return (
<AnimatePresence>
{open && <Primitive.Portal forceMount>
<Primitive.SubContent {...{ asChild: true }} forceMount sideOffset={sideOffset} alignOffset={alignOffset} collisionPadding={8} {...props}>
<motion.div data-slot="context-menu-sub-content"
initial={{ opacity: 0, scale: reduced ? 1 : 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.98, transition: fade }}
transition={safe(spring.snappy)}
className={cn("z-50 min-w-40 origin-(--radix-context-menu-content-transform-origin) rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none", className)}>
{children}
</motion.div>
</Primitive.SubContent>
</Primitive.Portal>}
</AnimatePresence>
)
}
export function ContextMenuSeparator(
props: React.ComponentProps<typeof Primitive.Separator>
) {
return <Primitive.Separator className="my-1 h-px bg-border" {...props} />
}Depends on
Wraps one focusable element, which opens the menu on right-click, long-press, the ContextMenu key or Shift+F10.
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | Lets the browser's own context menu show instead. |
Also accepts Radix ContextMenu.Item props.
| Prop | Type | Default | Description |
|---|---|---|---|
| onSelect | (event: Event) => void | — | Called when the item is chosen. Call event.preventDefault() to keep the menu open. |
| shortcut | string | string[] | — | Shortcut hint on the right, drawn with Kbd: "mod+d" shows ⌘D on Apple platforms and Ctrl+D elsewhere. Display only; bind the key yourself. |
| destructive | boolean | false | Styles the item as a destructive action. |
| disabled | boolean | false | Skipped by pointer and keyboard. |
Wraps a ContextMenuSubTrigger and its ContextMenuSubContent. Also accepts Radix ContextMenu.Sub props.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state of the submenu. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called when the submenu opens or closes. |
Also accepts Radix ContextMenu.SubContent props.
| Prop | Type | Default | Description |
|---|---|---|---|
| sideOffset | number | 4 | Gap between the parent menu and the submenu in pixels. |
| alignOffset | number | -5 | Shifts the submenu so its first item lines up with the trigger. |
<ContextMenuSub>
<ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
<ContextMenuSubContent>
{folders.map((folder) => (
<ContextMenuItem key={folder.id} onSelect={() => move(folder)}>
{folder.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>