ContextMenu
Right-click and keyboard context menu.
Live demo
01Right-click a file row
Right-click on the row, or focus it and press Shift+F10. Use β/β to navigate, Enter to select, Esc to close.
Minimal menu
Three items, no dividers, no shortcuts.
Danger styling
Items with danger: true render in red so destructive choices read at a glance.
Programmatic Shift+F10 trigger
Clicking the button below focuses the row and dispatches a synthetic Shift+F10 keyboard event β the same path screen-reader users take to open a menu.
Viewport-edge clamping
The trigger lives in the bottom-right corner of a constrained box. The menu measures itself after mount and shifts back into the visible area, so it never gets clipped.
Implementation
02<script lang="ts">
import ContextMenu from '$lib/components/ContextMenu.svelte';
const items = [
{ id: 'edit', label: 'Edit', shortcut: 'βE' },
{ id: 'copy', label: 'Copy', shortcut: 'βC' },
{ type: 'divider' as const },
{ id: 'delete', label: 'Delete', danger: true, shortcut: 'β«' }
];
</script>
β
<ContextMenu {items} onSelect={(id) => console.log('selected', id)}>
<div>Right-click me</div>
</ContextMenu>ContextMenu intercepts the contextmenu event on its trigger and renders the menu only while open, so when closed there is zero DOM cost beyond the wrapper. Right-click opens at the click point; Shift+F10 and the dedicated menu key open anchored to the trigger. β/β navigate (skipping dividers and disabled items), Home/End jump to the ends, Enter activates, Esc closes β full WAI-ARIA menu pattern. clampToViewport flips the menu after mount when it would overflow, with an 8px safety padding.
Logic explainer
03What Does It Do? (Plain English)
A right-click / long-press menu primitive. Wrap any trigger content in <ContextMenu> and right-click on it shows a custom menu at the click position β the native browser menu is suppressed. Items are passed in declaratively as a prop array, including dividers and danger-styled destructive choices. Pointer and keyboard parity from line one: Shift+F10 and the dedicated ContextMenu key open the menu without a mouse, arrow keys navigate (skipping dividers and disabled items), Enter activates, Escape closes.
Think of it as the "dropdown that follows the cursor" β file-tree row actions, image overlays, table-row context menus. Where a button's dropdown is anchored to the button, this menu is anchored to the click point.
How It Works (Pseudo-Code)
state:
open = boolean
position = { x, y } in viewport coords
activeIndex = currently-highlighted item (skips dividers/disabled)
reduced = prefers-reduced-motion check (set onMount)
triggerEl = ref to the wrapped content
menuEl = ref to the menu (only when open)
events:
on contextmenu (right-click) on trigger:
preventDefault // suppress native menu
openAt(event.clientX, event.clientY)
on keydown on trigger (Shift+F10 or ContextMenu key):
rect = triggerEl.getBoundingClientRect()
openAt(rect.left, rect.bottom)
openAt(x, y):
if disabled or no items: return
position = { x, y }
open = true
activeIndex = nextEnabledIndex(items, -1, +1) // first enabled
tick():
rect = menuEl.getBoundingClientRect()
position = clampToViewport(x, y, rect.width, rect.height, viewW, viewH)
itemEls[activeIndex].focus()
on menu keydown:
ArrowDown: activeIndex = nextEnabledIndex(..., +1); focus
ArrowUp: activeIndex = nextEnabledIndex(..., -1); focus
Home: activeIndex = nextEnabledIndex(items, -1, +1)
End: activeIndex = nextEnabledIndex(items, length, -1)
Enter / Space: selectIndex(activeIndex)
Escape / Tab: close
selectIndex(i):
if items[i] is divider or disabled: return
fire onSelect(items[i].id)
close()
on window mousedown (svelte:window):
if open and target outside menu and outside trigger: close()
close():
open = false
triggerEl?.focus() // return focus to triggerThe menu is fixed-positioned, mounted only when open. Closed state has zero menu DOM cost beyond the trigger wrapper.
The Core Concept: Viewport Clamping
A right-click near the bottom-right corner of the screen would naΓ―vely render the menu off the visible area. The fix is clampToViewport β a pure function that flips the menu when it would overflow:
clampToViewport(x, y, menuW, menuH, viewportW, viewportH, padding=8):
if x + menuW + padding > viewportW: x = max(padding, x - menuW) // flip left
if y + menuH + padding > viewportH: y = max(padding, y - menuH) // flip up
if x < padding: x = padding // clamp left
if y < padding: y = padding // clamp top
return { x, y }So a click at (viewportW - 50, viewportH - 50) doesn't open a menu that runs off the corner. It opens a menu whose bottom-right corner is at the click point β the menu grows up-and-left from the click. This is the macOS Finder behaviour and matches user expectation.
The 8 px padding ensures the menu never sits flush against the viewport edge β small comfort margin for users who move the cursor immediately after clicking.
The function is exported from the module script for unit testing β you can call clampToViewport(990, 700, 200, 300, 1024, 768) directly in vitest without rendering anything.
Pointer + Keyboard Parity
A common shortcut is to make the menu pointer-driven and bolt keyboard support on later. This component does the opposite: keyboard support is built in from the start, exported as a pure helper:
nextEnabledIndex(items, current, direction):
for step in 1..items.length:
i = (current + direction * step) wrapped into [0, length)
if items[i] is interactive and not disabled: return i
return -1ArrowDown calls nextEnabledIndex(items, activeIndex, +1). ArrowUp passes -1. Home is "find first enabled from -1 going forward". End is "find last enabled from length going backward". Wrapping is built in β ArrowDown on the last enabled item lands on the first.
Disabled items and dividers are both skipped: the type guard isInteractiveItem strips dividers, and the !item.disabled check strips disabled items. So a menu of [Edit, divider, Copy, Delete (disabled), divider, Quit] cycles Edit β Copy β Quit β Edit.
Suppression of the Native Menu
The trigger wrapper has oncontextmenu={handleContextMenu}, which calls event.preventDefault() before opening the custom menu. Without this, both menus would open simultaneously β the OS menu and ours, stacked. The preventDefault happens unconditionally (even if the trigger is disabled, in which case we then bail without opening anything) because letting the native menu show on a disabled trigger is more disruptive than suppressing it.
State Flow Diagram
ββββββββββββββββββββββββββββββββ
β CLOSED β
β open=false, no menu in DOM β
ββββββββββββ¬βββββββββββββββββββββ
β
ββββββββββββββΌββββββββββββββ¬ββββββββββββββ
β β β β
right-click Shift+F10 / keyboard: parent passes
on trigger ContextMenu focus on items but never
key on trigger trigger clicks (n/a)
β β β
βΌ βΌ βΌ
ββββββββββββββββββββββββββββββββ
β OPENING β
β position = click coords β
β open = true β
β activeIndex = first enabled β
β tick β measure β clamp β β
β focus active item β
ββββββββββββ¬βββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββ
β OPEN β
β ArrowKeys cycle activeIndex β
β (skip dividers/disabled) β
β Enter/Space activates β
ββββββββββββ¬βββββββββββββββββββββ
β
ββββββββββββββββββΌββββββββββββββββββ
β β β
Enter / Space Escape / Tab click outside
on enabled item (svelte:window)
β β β
βΌ βΌ βΌ
onSelect(id) close β triggerEl.focus()
β
βΌ
close() β triggerEl.focus()
β
βΌ
back to CLOSEDProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items |
ContextMenuItem[] |
required | Menu items. Each is either { type: 'divider' } or { id, label, shortcut?, disabled?, danger? }. Validated via normalizeItems; invalid entries dropped. |
onSelect |
(id: string) => void |
no-op | Fires on item click / Enter / Space with the selected item's id. |
ariaLabel |
string |
'Context menu' |
Used as the menu's aria-label. |
disabled |
boolean |
false |
Trigger ignores contextmenu and keyboard activators. |
class |
string |
'' |
Extra classes on the trigger wrapper. |
children |
Snippet |
default text | Trigger content rendered inside the wrapper. |
Item shape
type ContextMenuItem =
| { type: 'divider' }
| {
id: string; // unique stable identifier passed to onSelect
label: string; // visible label
shortcut?: string; // optional right-aligned hint, e.g. 'βC'
disabled?: boolean; // greyed out, skipped by keyboard nav, no click effect
danger?: boolean; // styled in red for destructive actions
};Edge Cases
| Situation | Behaviour |
|---|---|
| Right-click near the bottom-right corner | clampToViewport flips the menu so its bottom-right corner is at the click point. Menu grows up-and-left. |
| Right-click in the very corner with menu larger than viewport | Both axes clamp to padding (8 px from each edge). The menu may overlap the click point in extreme cases. |
items contains an entry with no id or no label |
normalizeItems drops it silently. Invalid items never reach the render. |
Two items with the same id |
First wins; second is dropped by normalizeItems. |
| All items disabled | Menu opens; nextEnabledIndex returns -1; no item gets focus. Keyboard nav is a no-op. Escape closes. |
User has prefers-reduced-motion: reduce |
The 120 ms scale-up open animation is replaced with instant render. The contract β open, navigate, select, close β is preserved. |
| User clicks inside the menu but on the gap between items | No item handler fires; svelte:window mousedown sees the click is inside menuEl and doesn't close. |
| User Shift-Tabs back to the trigger while menu is open | Tab handler closes the menu; focus returns to trigger. Shift+Tab moves to the previous focusable element. |
Menu opens, parent re-renders items to a different array |
safeItems = $derived(normalizeItems(items)) re-runs; the menu's contents update. activeIndex may now point at a different item β no crash, but the highlight may visibly jump. |
Dependencies
- Svelte 5.x β
$state,$derived,$props,onMount,tick, snippets,svelte:window. The pure helpers (normalizeItems,clampToViewport,nextEnabledIndex,isInteractiveItem,isReducedMotion) are exported from the module script for testing without a DOM. - Zero external dependencies. Native event handling, scoped CSS, no animation library.
File Structure
src/lib/components/ContextMenu.svelte # implementation
src/lib/components/ContextMenu.md # this file (rendered inside ComponentPageShell)
src/lib/components/ContextMenu.test.ts # vitest unit tests (uses exported helpers)
src/routes/contextmenu/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
items | ContextMenuItem[] | β | Required. Either { id, label, shortcut?, disabled?, danger? } or { type: "divider" }. |
onSelect | (id) => void | β | Fires when an item is activated. |
ariaLabel | string | 'Context menu' | Menu label for assistive tech. |
disabled | boolean | false | Ignore right-click and keyboard activators on the trigger. |
children | Snippet | β | The trigger element(s) to wrap. |