Context Menu
Menu styled for right-click use, built on the @foldkit/ui Menu submodel. ⚠ Opens on activation at a fixed anchor, not at the pointer — foldkit has no pointer-position anchor yet.
Differences vs shadcn/ui
- Right-click opens the menu anchored to the trigger region — foldkit has no pointer-position anchoring primitive yet.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/context-menuSource
The component ships as plain source — no build step, no wrapper. Copy it and make it yours.
registry/default/ui/context-menu.ts151 lines/** ⚠ BEHAVIOR GAP vs upstream shadcn: does not open on right-click / long-press; it opens on activation at a fixed anchor (foldkit has no pointer-position anchor primitive yet).
* The styled surface matches, but this behavior is absent — do not use
* where that behavior is required.
*/
/** Stateful submodel — import the whole module as a namespace and wire its
* Model/Message/init/update into your app:
* `import * as ContextMenu from '@/components/ui/context-menu'`
*/
import { Menu as FoldkitMenu } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/menu'
import { childAttributes, inertHtml, type Html } from 'foldkit/html'
import { cn } from '@/lib/utils'
// Re-export the @foldkit/ui Menu surface. A context menu is a Menu variant
// anchored to the top-start of a trigger region, styled like shadcn's
// `context-menu`. (The foldkit Menu opens on activation; wire a region trigger
// to open it from a right-click handler if you want literal contextmenu
// behavior.)
export const create = FoldkitMenu.create
export const init = (config: InitConfig): Model => FoldkitMenu.init({ isAnimated: true, ...config })
export const buttonId = FoldkitMenu.buttonId
export const Model = FoldkitMenu.Model
export type Model = typeof Model.Type
export const Message = FoldkitMenu.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitMenu.OutMessage
export type OutMessage = typeof OutMessage.Type
// foldcn gap vs upstream: opens on activation at a fixed anchor — foldkit has
// no right-click/pointer-position anchoring primitive (wire a region trigger
// yourself). Items highlight via data-active (upstream focus:) per the
// derivation mapping. The panel's data-side is emitted from the anchor
// placement (foldkit anchors expose placement, not side). Flat item path uses
// correct tokens/slots; per-item data-slot (context-menu-item etc) cannot be
// stamped — primitive has no per-item attribute hook. Submenu/checkbox/radio/
// destructive/inset kinds require primitive work. Pointer-anchoring is
// primitive ceiling — tokens/slots only.
export type Bundle<Item extends string = string> = FoldkitMenu.Bundle<Item>
export type InitConfig = FoldkitMenu.InitConfig
export type ViewInputs<Item extends string = string> = FoldkitMenu.ViewInputs<Item>
export type ItemConfig = FoldkitMenu.ItemConfig
export type GroupHeading = FoldkitMenu.GroupHeading
export const contextMenuTriggerClass = 'select-none'
export const contextMenuItemsClass =
'data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 not-data-[side=bottom]:data-[placement=bottom]:slide-in-from-top-2 not-data-[side=left]:data-[placement=left]:slide-in-from-right-2 not-data-[side=right]:data-[placement=right]:slide-in-from-left-2 not-data-[side=top]:data-[placement=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 animate-none! relative before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! bg-popover z-50 max-h-(--available-height) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none'
export const contextMenuItemsAnimatedClass = contextMenuItemsClass
export const contextMenuItemClass =
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-active:bg-accent data-active:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const contextMenuSeparatorClass = 'bg-border -mx-1 my-1 h-px'
export const contextMenuHeadingClass = 'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7'
export const contextMenuShortcutClass = 'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest group-data-active/context-menu-item:text-accent-foreground'
export const contextMenuSubTriggerClass =
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const contextMenuBackdropClass = 'fixed inset-0 z-0'
export const contextMenuWrapperClass = 'relative inline-block'
export const CONTEXT_MENU_ANCHOR: AnchorConfig = {
placement: 'bottom-start',
gap: 4,
padding: 8,
}
export type ContextMenuViewInputsConfig<Item extends string> = Readonly<{
items: ReadonlyArray<Item>
itemToConfig: ViewInputs<Item>['itemToConfig']
buttonContent: Html
anchor?: AnchorConfig
isItemDisabled?: (item: Item, index: number) => boolean
itemToSearchText?: (item: Item, index: number) => string
isButtonDisabled?: boolean
isAnimated?: boolean
itemGroupKey?: (item: Item, index: number) => string
groupToHeading?: (groupKey: string) => GroupHeading | undefined
triggerClass?: string
itemsClass?: string
itemClass?: string
backdropClass?: string
wrapperClass?: string
separatorClass?: string
groupClass?: string
ariaLabel?: string
ariaLabelledBy?: string
}>
/** Build styled `Menu.ViewInputs` for a context menu with foldcn's classes. */
export const viewInputs = <Item extends string>(
config: ContextMenuViewInputsConfig<Item>,
): ViewInputs<Item> => {
// Upstream slide-in variants key on data-side; foldkit anchors expose
// placement ("bottom-start"), so derive the physical side here.
const anchor = config.anchor ?? CONTEXT_MENU_ANCHOR
const side = (anchor.placement ?? 'bottom').split('-')[0] || 'bottom'
return {
items: config.items,
anchor,
isItemDisabled: config.isItemDisabled,
itemToSearchText: config.itemToSearchText,
isButtonDisabled: config.isButtonDisabled,
buttonContent: config.buttonContent,
itemGroupKey: config.itemGroupKey,
groupToHeading: config.groupToHeading
? (groupKey) => {
const heading = config.groupToHeading!(groupKey)
if (!heading) return undefined
return {
content: heading.content,
className: cn(contextMenuHeadingClass, heading.className),
}
}
: undefined,
ariaLabel: config.ariaLabel,
ariaLabelledBy: config.ariaLabelledBy,
buttonClassName: cn(contextMenuTriggerClass, config.triggerClass),
buttonAttributes: childAttributes([inertHtml.DataAttribute('slot', 'context-menu-trigger')]),
itemsClassName: cn(
config.isAnimated !== false ? contextMenuItemsAnimatedClass : contextMenuItemsClass,
config.itemsClass,
),
itemsAttributes: childAttributes([
inertHtml.DataAttribute('slot', 'context-menu-content'),
inertHtml.DataAttribute('side', side),
]),
itemToConfig: (item, context) => {
const { className, content } = config.itemToConfig(item, context)
return { className: cn(contextMenuItemClass, config.itemClass, className), content }
},
separatorClassName: cn(contextMenuSeparatorClass, config.separatorClass),
separatorAttributes: childAttributes([
inertHtml.DataAttribute('slot', 'context-menu-separator'),
]),
groupClassName: config.groupClass,
groupAttributes: childAttributes([inertHtml.DataAttribute('slot', 'context-menu-group')]),
backdropClassName: cn(contextMenuBackdropClass, config.backdropClass),
className: cn(contextMenuWrapperClass, config.wrapperClass),
attributes: childAttributes([inertHtml.DataAttribute('slot', 'context-menu')]),
}
}