icon

Morainev0.5.0

ContextMenu
overlayscontext-menu

ContextMenu

Menu triggered by right-click or long press on its child content.

Import#

import { ContextMenu } from 'moraine'

Slot Structure#

Right-click trigger with a floating menu portal containing grouped items.

Use contentProps and itemProps to forward native attributes, refs, and event handlers. Item handlers run before built-in menu behavior and can call preventDefault() to cancel activation.

trigger
└── content (portal)
└── group (×n)
├── label (optional)
├── separator (optional)
└── item (×n)

Item internals#

item
├── itemLeading (optional)
├── itemWrapper
│ ├── itemLabel (optional)
│ └── itemDescription (optional)
└── itemTrailing
└── itemIndicator (optional, checkbox items)

Examples#

Sizes#

Menu item size scale from sm to lg for compact and roomy density.

function Sizes() {
const SIZES = ['sm', 'md', 'lg'] as const
const ITEMS: ContextMenuT.Item[] = [
{
type: 'group',
children: [
{
label: 'Open',
icon: 'i-lucide-folder-open',
},
{
label: 'Rename',
icon: 'i-lucide-pencil',
},
{
label: 'Delete',
icon: 'i-lucide-trash-2',
color: 'destructive',
},
],
},
]
return (
<div class="flex flex-wrap gap-3">
<For each={SIZES}>
{(size) => (
<ContextMenu size={size} items={ITEMS}>
<Button variant="outline">Right click ({size})</Button>
</ContextMenu>
)}
</For>
</div>
)
}

Placements#

Same file menu rendered with top/right/bottom/left placements for quick transition-direction sanity checks.

function Placements() {
const surfaceClass =
'flex h-24 w-full items-center justify-center rounded-lg b-1 b-border border-border bg-background text-sm text-foreground'
const badgeClass =
'rounded-md b-1 b-border border-border bg-muted px-1.5 py-0.5 font-medium text-[11px] text-foreground'
const fileItems: ContextMenuT.Item[] = [
{
type: 'group',
label: 'File Actions',
children: [
{
label: 'Open File',
icon: 'i-lucide-file-code-2',
kbds: ['↵'],
},
{
label: 'Open in Split View',
icon: 'i-lucide-split-square-horizontal',
kbds: ['⌘', '\\'],
},
{
label: 'Reveal in Explorer',
icon: 'i-lucide-folder-search-2',
},
{ type: 'separator' },
{
label: 'Move To…',
icon: 'i-lucide-folder-input',
children: [
{
type: 'group',
label: 'Recent Folders',
children: [
{
label: 'src/overlays',
icon: 'i-lucide-folder-open',
},
{
label: 'src/navigation',
icon: 'i-lucide-folder-open',
},
{
label: 'More Destinations',
icon: 'i-lucide-more-horizontal',
children: [
{
type: 'group',
children: [
{
label: 'docs/components',
icon: 'i-lucide-folder-open',
},
{
label: 'docs/content',
icon: 'i-lucide-folder-open',
},
],
},
],
},
],
},
],
},
{
label: 'Copy Path',
icon: 'i-lucide-copy',
kbds: ['⌘', '⌥', 'C'],
},
],
},
{
type: 'group',
children: [
{
label: (
<div class="flex gap-2 items-center">
<span>Rename</span>
<span class={badgeClass}>F2</span>
</div>
),
icon: 'i-lucide-pencil',
},
{
label: 'Delete',
icon: 'i-lucide-trash-2',
color: 'destructive',
},
],
},
]
return (
<div class="gap-3 grid sm:grid-cols-2">
<ContextMenu placement="top" items={fileItems}>
<div class={surfaceClass}>Right click (top)</div>
</ContextMenu>
<ContextMenu placement="right" items={fileItems}>
<div class={surfaceClass}>Right click (right)</div>
</ContextMenu>
<ContextMenu placement="bottom" items={fileItems}>
<div class={surfaceClass}>Right click (bottom)</div>
</ContextMenu>
<ContextMenu placement="left" items={fileItems}>
<div class={surfaceClass}>Right click (left)</div>
</ContextMenu>
</div>
)
}

File Explorer#

A file-row context menu with move flows, shortcuts, mixed labels, and destructive actions.

function FileExplorer() {
const badgeClass =
'rounded-md b-1 b-border border-border bg-muted px-1.5 py-0.5 font-medium text-[11px] text-foreground'
const [lastAction, setLastAction] = createSignal('None')
return (
<div class="flex flex-col gap-4">
<ContextMenu
items={[
{
type: 'group',
label: (
<div class="flex gap-2 items-center">
<span>Issue: Improve menu transitions</span>
<span class={badgeClass}>P1</span>
</div>
),
children: [
{
label: 'Open Issue',
icon: 'i-lucide-external-link',
onSelect: () => setLastAction('Open issue'),
},
{
label: 'Assign',
icon: 'i-lucide-user-round-plus',
children: [
{
type: 'group',
children: [
{
label: 'Alex Morgan',
description: 'Design systems',
icon: 'i-lucide-user',
onSelect: () => setLastAction('Assign Alex Morgan'),
},
{
label: 'Jamie Chen',
description: 'Overlay primitives',
icon: 'i-lucide-user',
onSelect: () => setLastAction('Assign Jamie Chen'),
},
],
},
],
},
{
label: 'Move to Sprint',
icon: 'i-lucide-calendar-range',
children: [
{
type: 'group',
children: [
{
label: 'Sprint 18',
onSelect: () => setLastAction('Move to Sprint 18'),
},
{
label: 'Sprint 19',
onSelect: () => setLastAction('Move to Sprint 19'),
},
{
label: 'Backlog',
onSelect: () => setLastAction('Move to backlog'),
},
],
},
],
},
],
},
{
type: 'group',
children: [
{
label: 'Edit Details',
icon: 'i-lucide-pencil',
onSelect: () => setLastAction('Edit details'),
},
{
label: 'Share Update',
icon: 'i-lucide-share-2',
onSelect: () => setLastAction('Share update'),
},
{ type: 'separator' },
{
label: 'Archive',
icon: 'i-lucide-archive',
onSelect: () => setLastAction('Archive issue'),
},
{
label: 'Delete Issue',
icon: 'i-lucide-trash-2',
color: 'destructive',
onSelect: () => setLastAction('Delete issue'),
},
],
},
]}
>
<div class="text-sm text-foreground p-4 b-1 b-border border-border rounded-lg bg-background flex flex-col min-h-28 w-full justify-between">
<div class="flex gap-3 items-center justify-between">
<div>
<div class="text-foreground font-medium">dropdown-menu.tsx</div>
<div class="text-xs text-muted-foreground">src/overlays/dropdown-menu</div>
</div>
<span class={badgeClass}>Modified</span>
</div>
<div class="text-xs text-muted-foreground">Right click this file row</div>
</div>
</ContextMenu>
<p class="text-sm text-muted-foreground px-4">
Last action: <span class="font-medium">{lastAction()}</span>
</p>
</div>
)
}

Editor Selection#

A code-editor-style context menu with refactors, toggles, and theme switching for keyboard and pointer testing.

function EditorSelection() {
const [showMinimap, setShowMinimap] = createSignal(true)
const [showStickyScroll, setShowStickyScroll] = createSignal(true)
const [showInlineHints, setShowInlineHints] = createSignal(false)
const [editorTheme, setEditorTheme] = createSignal<'light' | 'dark' | 'system'>('dark')
const editorItems = createMemo<ContextMenuT.Item[]>(() => [
{
type: 'group',
label: 'Editor Selection',
children: [
{
label: 'Quick Fix…',
icon: 'i-lucide-wand-sparkles',
kbds: ['⌘', '.'],
},
{
label: 'Refactor',
icon: 'i-lucide-git-branch-plus',
children: [
{
type: 'group',
children: [
{
label: 'Extract Variable',
icon: 'i-lucide-variable',
},
{
label: 'Extract Function',
icon: 'i-lucide-braces',
},
{
label: 'Move to File…',
icon: 'i-lucide-file-output',
},
],
},
],
},
{ type: 'separator' },
{
type: 'checkbox',
label: 'Show Minimap',
icon: 'i-lucide-map',
checked: showMinimap(),
onCheckedChange: (checked: boolean) => setShowMinimap(checked),
},
{
type: 'checkbox',
label: 'Sticky Scroll',
icon: 'i-lucide-panel-top',
checked: showStickyScroll(),
onCheckedChange: (checked: boolean) => setShowStickyScroll(checked),
},
{
type: 'checkbox',
label: 'Inline Hints',
icon: 'i-lucide-message-square-quote',
checked: showInlineHints(),
onCheckedChange: (checked: boolean) => setShowInlineHints(checked),
},
],
},
{
type: 'group',
label: 'Theme',
children: [
{
label: 'Light',
icon: editorTheme() === 'light' ? 'i-lucide-check' : 'i-lucide-sun',
onSelect: () => setEditorTheme('light'),
},
{
label: 'Dark',
icon: editorTheme() === 'dark' ? 'i-lucide-check' : 'i-lucide-moon',
onSelect: () => setEditorTheme('dark'),
},
{
label: 'System',
icon: editorTheme() === 'system' ? 'i-lucide-check' : 'i-lucide-monitor',
onSelect: () => setEditorTheme('system'),
},
],
},
])
return (
<div class="flex flex-col">
<ContextMenu items={editorItems()}>
<div class="text-sm text-foreground p-4 b-1 b-border border-border rounded-lg bg-background flex flex-col min-h-28 w-full justify-between">
<div class="text-xs text-foreground font-mono">
const motion = resolveOverlayMenuSide(placement)
</div>
<div class="text-xs text-muted-foreground">Right click the selected line</div>
</div>
</ContextMenu>
<div class="text-sm text-muted-foreground mt-3 flex flex-wrap gap-4">
<span>
Minimap: <span class="font-medium">{String(showMinimap())}</span>
</span>
<span>
Sticky scroll: <span class="font-medium">{String(showStickyScroll())}</span>
</span>
<span>
Inline hints: <span class="font-medium">{String(showInlineHints())}</span>
</span>
<span>
Theme: <span class="font-medium uppercase">{editorTheme()}</span>
</span>
</div>
</div>
)
}

Project / Issue Actions#

A denser project card menu with assignee and sprint submenus plus archive/delete actions.

function ProjectIssueActions() {
const panelClass =
'flex min-h-28 w-full flex-col justify-between rounded-lg b-1 b-border border-border bg-background p-4 text-sm text-foreground'
const projectItems: ContextMenuT.Item[] = [
{
type: 'group',
label: (
<div class="flex gap-2 items-center">
<span>Issue: Improve menu transitions</span>
<span class={badgeClass}>P1</span>
</div>
),
children: [
{
label: 'Open Issue',
icon: 'i-lucide-external-link',
},
{
label: 'Move to Sprint',
icon: 'i-lucide-calendar-range',
children: [
{
type: 'group',
children: [
{
label: 'Sprint 18',
},
{
label: 'Sprint 19',
},
{
label: 'Sprint 20',
},
{
label: 'Sprint 21',
},
{
label: 'Sprint 22',
},
{
label: 'Backlog',
},
],
},
],
},
{
label: 'Edit Details',
icon: 'i-lucide-pencil',
},
{
label: 'Assign',
icon: 'i-lucide-user-round-plus',
children: [
{
type: 'group',
children: [
{
label: 'Alex Morgan',
description: 'Design systems',
icon: 'i-lucide-user',
},
{
label: 'Jamie Chen',
description: 'Overlay primitives',
icon: 'i-lucide-user',
},
{
label: 'John Smith',
description: 'Test',
icon: 'i-lucide-circle',
},
],
},
],
},
{
label: 'Share Update',
icon: 'i-lucide-share-2',
},
{ type: 'separator' },
{
label: 'Archive',
icon: 'i-lucide-archive',
},
{
label: 'Delete Issue',
icon: 'i-lucide-trash-2',
color: 'destructive',
},
],
},
]
return (
<ContextMenu items={projectItems}>
<div class={panelClass}>
<div class="flex gap-3 items-center justify-between">
<div>
<div class="text-foreground font-medium">
Improve dropdown/context menu motion polish
</div>
<div class="text-xs text-muted-foreground">Overlay milestone · due this sprint</div>
</div>
<span class={badgeClass}>In Review</span>
</div>
<div class="text-xs text-muted-foreground">Right click this card</div>
</div>
</ContextMenu>
)
}

API Reference#

Attributes#

Slottrigger6 attributes
Element users activate to open the menu.

Data Attributes

3
Data AttributeDescription
data-closed
State or slot attribute exposed for styling hooks and selectors.
data-disabled
Present when the component or item is disabled.
data-expanded
State or slot attribute exposed for styling hooks and selectors.

ARIA Attributes

3
ARIA AttributeDescription
aria-controls
References the controlled element while the related content is mounted.
aria-expanded
Indicates whether the controlled content is expanded.
aria-haspopup
Accessibility attribute forwarded by the rendered component.

Props#

PropTypeDefaultDescription
checkedIconIconT.Nameicon-check
Icon used for checked checkbox items.
childrenJSX.Element
Target area that opens the context menu on right-click or long press.
classClassValue
Class applied to the component root or trigger element.
classes(OverlayMenuSharedClasses & ContextMenuT.Classes) | undefined
Slot class overrides for menu sections.
contentBottomOverlayMenuContentSlot | undefined
Content rendered after the resolved item groups.
contentPropsElementProps<HTMLDivElement> | undefined
Additional attributes for each menu layer content element.
contentTopOverlayMenuContentSlot | undefined
Content rendered before the resolved item groups.
defaultOpenboolean | undefinedfalse
Initial open state when the component is uncontrolled.
disabledboolean | undefinedfalse
Whether trigger interactions should be ignored.
gutternumber | undefined0
Gap between the anchor and the content.
idstring | undefined
Unique base id used to derive trigger and content ids.
itemProps((props: ContextMenuT.ItemRenderProps) => ElementProps<HTMLDivElement> | undefined) | undefined
Additional attributes for an interactive menu item.
itemRenderComponentOrElement<ContextMenuT.ItemRenderProps> | undefined
Custom renderer for individual items.
itemsContextMenuT.Item[] | undefined
Items rendered in the menu body.
onContextMenuJSX.EventHandlerUnion<HTMLSpanElement, MouseEvent> | undefined
onKeyDownJSX.EventHandlerUnion<HTMLSpanElement, KeyboardEvent> | undefined
onOpenChange((open: boolean) => void) | undefined
Called whenever the menu requests an open state change.
onPointerCancelJSX.EventHandlerUnion<HTMLSpanElement, PointerEvent> | undefined
onPointerDownJSX.EventHandlerUnion<HTMLSpanElement, PointerEvent> | undefined
onPointerMoveJSX.EventHandlerUnion<HTMLSpanElement, PointerEvent> | undefined
onPointerUpJSX.EventHandlerUnion<HTMLSpanElement, PointerEvent> | undefined
openboolean | undefined
Controlled open state of the menu.
overflowPaddingnumber | undefined4
Padding applied to the overflow area when calculating the menu's position.
placementOverlayMenuPlacement | undefined
Preferred content placement relative to the trigger or anchor point.
preventScrollboolean | undefinedtrue
Whether body scroll should be locked while the menu is open.
refJSX.HTMLElementTags["span"] extends { ref?: infer Ref; } ? Ref : never | undefined
size"sm" | "md" | "lg" | undefinedmd
Menu item size variant.
styleJSX.CSSProperties | undefined
styles(OverlayMenuSharedStyles & ContextMenuT.Styles) | undefined
Slot style overrides for menu sections.
submenuIconIconT.Nameicon-chevron-right
Icon used for submenu trigger items.

Items#

PropTypeDefaultDescription
checkedboolean | undefined
Controlled checked state for checkbox and radio items.
childrenContextMenuT.Item[] | undefined
Nested menu items for creating submenus.
colorNonNullable<"default" | "destructive" | undefined> | undefined
Color theme variant for the menu item.
defaultCheckedboolean | undefined
Initial checked state for uncontrolled checkbox and radio items.
defaultOpenboolean | undefined
Initial open state for submenus.
descriptionJSX.Element
Secondary description text displayed below the label.
disabledboolean | undefinedfalse
Whether the item is non-interactive.
groupstring | undefined
Radio group identifier. Radio items with the same group are mutually exclusive.
iconIconT.Name
Icon name or custom element to display at the start of the item.
kbdsstring[] | undefined
Array of keyboard shortcuts to display as keys.
labelJSX.Element
Primary label text or element.
onCheckedChange((checked: boolean) => void) | undefined
Event handler called when a checkbox item's state changes.
onSelect(() => void) | undefined
Event handler called when the item is activated.
onValueChange((value: string) => void) | undefined
Event handler called when a radio item is selected.
openboolean | undefined
Controlled open state for submenus.
typeOverlayMenuItemType | undefineditem
The type of menu item to render.
valuestring | undefined
Radio item value reported by onValueChange and used for grouped selection.