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.

Preview
Style

Last action: none yet · Bookmarks off · Full URLs on · Person: Pedro Duarte

Basic

Right-click opens the menu (left-click works too). Anchors to the region.

With Icons
Anchored Above
src/demo/views/context-menu.ts355 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Check, Clipboard, Copy, Scissors, Trash2 } from 'lucide'

import { Menu as FoldkitMenu } from '@foldkit/ui'

import * as ContextMenu from '../../generated/registry/ui/context-menu'
import { icon } from '../../generated/registry/lib/icons'
import { DemoMenu } from '../bundles'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

export const Message = defineMessageUnion({
  GotCtxBasicMenuMessage: { message: ContextMenu.Message },
  GotCtxIconsMenuMessage: { message: ContextMenu.Message },
  GotCtxTopMenuMessage: { message: ContextMenu.Message },
})

// Sections mirror apps/v4/examples/base/context-menu-demo.tsx. Right-click on
// a region opens its menu: foldkit's `h.OnContextMenu` suppresses the native
// menu and the handler injects the primitive's `Opened` message. The panel
// anchors to the trigger region (not the pointer — primitive ceiling, see
// gapsByItem['context-menu']). Submenu/checkbox/radio rows render as flat
// items with demo-owned state, as in the menu demo.

const BASIC_ITEMS = [
  'Back',
  'Forward',
  'Reload',
  'Save Page...',
  'Create Shortcut...',
  'Name Window...',
  'Developer Tools',
  'Delete',
  'Show Bookmarks',
  'Show Full URLs',
  'Pedro Duarte',
  'Colm Tuite',
] as const

const BASIC_SHORTCUTS = new Map<string, string>([
  ['Back', '⌘['],
  ['Forward', '⌘]'],
  ['Reload', '⌘R'],
])

const basicGroupKey = (item: string): string => {
  if (item === 'Back' || item === 'Forward' || item === 'Reload') return 'nav'
  if (
    item === 'Save Page...' ||
    item === 'Create Shortcut...' ||
    item === 'Name Window...' ||
    item === 'Developer Tools' ||
    item === 'Delete'
  )
    return 'tools'
  if (item === 'Show Bookmarks' || item === 'Show Full URLs') return 'appearance'
  return 'people'
}

const ICON_ITEMS = ['Copy', 'Cut', 'Paste', 'Delete'] as const

const ICONS = new Map<string, typeof Copy>([
  ['Copy', Copy],
  ['Cut', Scissors],
  ['Paste', Clipboard],
  ['Delete', Trash2],
])

const ICON_SHORTCUTS = new Map<string, string>([
  ['Copy', '⌘C'],
  ['Cut', '⌘X'],
  ['Paste', '⌘V'],
  ['Delete', '⌫'],
])

const TOP_ITEMS = ['Back', 'Forward', 'Reload'] as const

const fields = {
  ctxBasicMenu: ContextMenu.Model,
  ctxIconsMenu: ContextMenu.Model,
  ctxTopMenu: ContextMenu.Model,
  showBookmarks: S.Boolean,
  showFullUrls: S.Boolean,
  person: S.String,
  lastContextAction: S.String,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

const basicCheckOn = (model: State, item: string): boolean => {
  if (item === 'Show Bookmarks') return model.showBookmarks
  if (item === 'Show Full URLs') return model.showFullUrls
  return false
}

const isBasicRadio = (item: string): boolean => item === 'Pedro Duarte' || item === 'Colm Tuite'

const basicItemContent = (model: State, h: HtmlBuilder<AppMessage>, item: string): Html => {
  const inner: Array<Html> = []
  if (item === 'Show Bookmarks' || item === 'Show Full URLs' || isBasicRadio(item)) {
    const on =
      item === 'Show Bookmarks' || item === 'Show Full URLs'
        ? basicCheckOn(model, item)
        : model.person === item
    inner.push(
      h.span(
        [h.Class('flex w-4 shrink-0 items-center justify-center')],
        [...(on ? [icon(h, Check, 'size-4')] : [])],
      ),
    )
  }
  inner.push(h.span([], [item]))
  const shortcut = BASIC_SHORTCUTS.get(item)
  if (shortcut !== undefined) {
    inner.push(h.span([h.Class(ContextMenu.contextMenuShortcutClass)], [shortcut]))
  }
  const destructive = item === 'Delete'
  return h.span(
    [
      h.Class(
        destructive
          ? 'flex w-full items-center gap-2 text-destructive data-active:bg-destructive/10 data-active:text-destructive dark:data-active:bg-destructive/20 [&_svg]:text-destructive'
          : 'flex w-full items-center gap-2',
      ),
    ],
    inner,
  )
}

export const contextMenuView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.p(
        [h.Class('px-1 text-xs text-muted-foreground')],
        [
          `Last action: ${model.lastContextAction} · Bookmarks ${model.showBookmarks ? 'on' : 'off'} · Full URLs ${model.showFullUrls ? 'on' : 'off'} · Person: ${model.person}`,
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.div(
            [
              h.Class(
                'flex aspect-[2/0.5] w-full items-center justify-center rounded-lg border border-dashed text-sm',
              ),
              h.OnContextMenu(
                Message.GotCtxBasicMenuMessage({
                  message: ContextMenu.Message.Opened({ maybeActiveItemIndex: Option.none() }),
                }),
              ),
            ],
            [
              h.submodel({
                slotId: model.ctxBasicMenu.id,
                model: model.ctxBasicMenu,
                view: DemoMenu.view,
                viewInputs: ContextMenu.viewInputs<string>({
                  items: BASIC_ITEMS,
                  buttonContent: h.span([], ['Right click here']),
                  itemsClass: 'w-48',
                  itemGroupKey: (item) => basicGroupKey(item),
                  groupToHeading: (groupKey) => {
                    // Upstream nests the tools under a "More Tools" submenu;
                    // without a submenu primitive they render as a group.
                    if (groupKey === 'tools') return { content: h.span([], ['More Tools']) }
                    if (groupKey === 'people') return { content: h.span([], ['People']) }
                    return undefined
                  },
                  isItemDisabled: (item) => item === 'Forward',
                  itemToConfig: (item) => ({ content: basicItemContent(model, h, item) }),
                }),
                toParentMessage: (message) => Message.GotCtxBasicMenuMessage({ message }),
              }),
            ],
          ),
          h.p(
            [h.Class('px-1 text-xs text-muted-foreground')],
            ['Right-click opens the menu (left-click works too). Anchors to the region.'],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Icons']),
          h.div(
            [
              h.Class(
                'flex aspect-[2/0.5] w-full items-center justify-center rounded-lg border border-dashed text-sm',
              ),
              h.OnContextMenu(
                Message.GotCtxIconsMenuMessage({
                  message: ContextMenu.Message.Opened({ maybeActiveItemIndex: Option.none() }),
                }),
              ),
            ],
            [
              h.submodel({
                slotId: model.ctxIconsMenu.id,
                model: model.ctxIconsMenu,
                view: DemoMenu.view,
                viewInputs: ContextMenu.viewInputs<string>({
                  items: ICON_ITEMS,
                  buttonContent: h.span([], ['Right click — Copy / Cut / Paste']),
                  itemsClass: 'w-44',
                  itemToConfig: (item) => ({
                    className:
                      item === 'Delete'
                        ? 'text-destructive data-active:bg-destructive/10 data-active:text-destructive dark:data-active:bg-destructive/20 [&_svg]:text-destructive'
                        : '',
                    content: h.span(
                      [h.Class('flex w-full items-center gap-2')],
                      [
                        icon(h, ICONS.get(item) ?? Copy),
                        h.span([], [item]),
                        h.span(
                          [h.Class(ContextMenu.contextMenuShortcutClass)],
                          [ICON_SHORTCUTS.get(item) ?? ''],
                        ),
                      ],
                    ),
                  }),
                }),
                toParentMessage: (message) => Message.GotCtxIconsMenuMessage({ message }),
              }),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Anchored Above']),
          h.div(
            [
              h.Class(
                'flex aspect-[2/0.5] w-full items-center justify-center rounded-lg border border-dashed text-sm',
              ),
              h.OnContextMenu(
                Message.GotCtxTopMenuMessage({
                  message: ContextMenu.Message.Opened({ maybeActiveItemIndex: Option.none() }),
                }),
              ),
            ],
            [
              h.submodel({
                slotId: model.ctxTopMenu.id,
                model: model.ctxTopMenu,
                view: DemoMenu.view,
                viewInputs: ContextMenu.viewInputs<string>({
                  items: TOP_ITEMS,
                  buttonContent: h.span([], ['Right click — opens above']),
                  anchor: { placement: 'top-start', gap: 4, padding: 8 },
                  itemsClass: 'w-36',
                  isItemDisabled: (item) => item === 'Forward',
                  itemToConfig: (item) => ({ content: h.span([], [item]) }),
                }),
                toParentMessage: (message) => Message.GotCtxTopMenuMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

const recordAction = (action: string) => (model: State) => ({
  model: evo(model, { lastContextAction: () => action }),
})

const foldBasicOutMessage = M.type<FoldkitMenu.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({
        model: evo(model, {
          showBookmarks: () =>
            value === 'Show Bookmarks' ? !model.showBookmarks : model.showBookmarks,
          showFullUrls: () =>
            value === 'Show Full URLs' ? !model.showFullUrls : model.showFullUrls,
          person: () => (value === 'Pedro Duarte' || value === 'Colm Tuite' ? value : model.person),
          lastContextAction: () => value,
        }),
      }),
  }),
)

const foldActionOutMessage = (prefix: string) =>
  M.type<FoldkitMenu.OutMessage>().pipe(
    M.withReturnType<Update.Step<State, unknown>>(),
    M.tagsExhaustive({ Selected: ({ value }) => recordAction(`${prefix}: ${value}`) }),
  )

const foldCtxBasic = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.ctxBasicMenu),
  write: (model, next) => evo(model, { ctxBasicMenu: () => next }),
  toParentMessage: (message) => Message.GotCtxBasicMenuMessage({ message }),
  foldOutMessage: foldBasicOutMessage,
})

const foldCtxIcons = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.ctxIconsMenu),
  write: (model, next) => evo(model, { ctxIconsMenu: () => next }),
  toParentMessage: (message) => Message.GotCtxIconsMenuMessage({ message }),
  foldOutMessage: foldActionOutMessage('Icons'),
})

const foldCtxTop = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.ctxTopMenu),
  write: (model, next) => evo(model, { ctxTopMenu: () => next }),
  toParentMessage: (message) => Message.GotCtxTopMenuMessage({ message }),
  foldOutMessage: foldActionOutMessage('Above'),
})

export const slice = defineSlice({
  fields,
  init: {
    ctxBasicMenu: ContextMenu.init({ id: 'context-menu-basic' }),
    ctxIconsMenu: ContextMenu.init({ id: 'context-menu-icons' }),
    ctxTopMenu: ContextMenu.init({ id: 'context-menu-top' }),
    showBookmarks: true,
    showFullUrls: false,
    person: 'Pedro Duarte',
    lastContextAction: 'none yet',
  },
  messages: [
    Message.GotCtxBasicMenuMessage,
    Message.GotCtxIconsMenuMessage,
    Message.GotCtxTopMenuMessage,
  ],
  handlers: (model: State) => ({
    GotCtxBasicMenuMessage: (payload: typeof Message.GotCtxBasicMenuMessage.Type): UpdateReturn =>
      foldCtxBasic(model, payload.message),
    GotCtxIconsMenuMessage: (payload: typeof Message.GotCtxIconsMenuMessage.Type): UpdateReturn =>
      foldCtxIcons(model, payload.message),
    GotCtxTopMenuMessage: (payload: typeof Message.GotCtxTopMenuMessage.Type): UpdateReturn =>
      foldCtxTop(model, payload.message),
  }),
  samples: [],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/context-menu

Source

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')]),
  }
}