@ozankurt/context-menu
v1.0.0
Published
Framework agnostic right click context menu with a zero dependency TypeScript core and React, Vue, jQuery and vanilla entry points.
Maintainers
Readme
@ozankurt/context-menu
A right click context menu for the web that belongs to a component, not to a
table, a grid or a selection model. The core is TypeScript with zero runtime
dependencies, binds by CSS selector so one listener serves a list of any
length, and renders into the browser's top layer through the Popover API, so
the menu is never clipped by an overflow: hidden ancestor, never mispositioned
by a transform ancestor and never in a z-index fight. React, Vue and jQuery
get thin adapters over that same core, and every event is also a bubbling DOM
CustomEvent, so a framework with no adapter is still a first class consumer.

Every image in this README is captured from examples/, which you can run
yourself with npm run examples. See examples/README.md.
- Install and entry points
- Quick start
- React | Vue | jQuery | Script tag
- The item model
- Item types
- Async items and beforeitems
- Selection aware menus
- Events
- Styling
- Placement and the top layer
- Keyboard and accessibility
- TypeScript
- Options reference
- Limitations
- Migrating from datatables-contextual-actions
The design document behind these decisions lives in
docs/superpowers/specs/2026-09-07-context-menu-design.md, and the browser test
suite is documented in docs/E2E.md. This README is the API.
Install and entry points
npm install @ozankurt/context-menu| Import path | What it gives you |
| --- | --- |
| @ozankurt/context-menu | ContextMenu, createContextMenu, Registry, the renderer, VERSION, every type |
| @ozankurt/context-menu/react | useContextMenu, ContextMenuProvider, useContextMenuInstance |
| @ozankurt/context-menu/vue | vContextMenu, useContextMenu, ContextMenuPlugin, useContextMenuInstance, CONTEXT_MENU_KEY |
| @ozankurt/context-menu/jquery | $.fn.contextMenu and registerJQueryPlugin |
| @ozankurt/context-menu/styles.css | The default stylesheet |
VERSION is the published version string, injected at build time.
CONTEXT_MENU_KEY is the Vue InjectionKey the plugin provides under, for a
component that would rather inject it than call the composable.
Every entry ships both an ESM and a CommonJS build, so import and require
both resolve, and declarations are published for each (.d.ts for import,
.d.cts for require). A CommonJS test runner such as Jest in its default mode
needs no transform and no moduleNameMapper entry.
There is also dist/context-menu.global.js, an IIFE build that puts the core
and registerJQueryPlugin on window.ContextMenu for a plain <script>
tag, from a CDN or from your own static assets. See Script tag.
The stylesheet is a separate import in every case. The build extracts CSS
rather than injecting it, so importing the JavaScript alone renders an unstyled
menu. Import @ozankurt/context-menu/styles.css once, anywhere in your app, or
link dist/styles.css.
React, Vue and jQuery are optional peer dependencies. None of them is imported by the core, and jQuery is never imported at all: the adapter reads it off the global at call time, so it stays out of your bundle graph.
Quick start
Any markup will do. The default metadata source is the element's data-*
attributes.
<button class="row" type="button" data-id="1" data-name="Report.pdf" data-locked="false">
Report.pdf
</button>import { createContextMenu } from '@ozankurt/context-menu'
import '@ozankurt/context-menu/styles.css'
const menu = createContextMenu()
menu.register({
on: '.row',
header: (meta) => String(meta.name),
items: [
{ label: 'Open', hint: 'Enter', action: (meta) => console.log('open', meta.id) },
{ label: 'Rename', disabled: (meta) => meta.locked === 'true' },
{ type: 'separator' },
{ label: 'Delete', variant: 'danger', action: (meta) => console.log('delete', meta.id) },
],
})That is the whole setup. menu.register puts one contextmenu listener on
the document however many definitions you add, and it matches by selector, so a
.row inserted into the page an hour later already has this menu with no
rebinding. Right clicking anything that is not a .row gets the browser's own
menu, which is the correct outcome and not an error.
Two details worth reading once, because they are the ones people get wrong:
meta.lockedis the string'true'above, not the boolean. The defaultresolveMetais a shallow copy ofel.dataset, anddatasetstringifies everything. The React and Vue adapters exist largely to fix this: they hand your predicates the real object. In vanilla, either compare against strings as above or supply your ownresolveMeta.hiddenanddisabledare different messages.hiddenremoves the item; an action the user may never take should not be advertised.disabledkeeps the row visible and announced, because an action the user cannot take right now should explain itself rather than vanish.

Both of those are on examples/states.html, along with a pair of switches that
change the underlying state so you can watch a row disappear or go inert between
two opens.
Prefer to bind one element rather than a selector? attach is the same
machinery with a generated selector, and its disposer cleans up both:
const dispose = menu.attach(document.querySelector('#row-1'), {
items: [{ label: 'Open', action: (meta) => console.log(meta) }],
})
dispose()React
import { useContextMenu } from '@ozankurt/context-menu/react'
import '@ozankurt/context-menu/styles.css'
const rowMenu = {
header: (meta) => String(meta.name),
items: [
{ label: 'Open', hint: 'Enter', action: (meta) => console.log('open', meta.id) },
{ label: 'Rename', disabled: (meta) => meta.locked },
{ type: 'separator' },
{ label: 'Delete', variant: 'danger', action: (meta) => console.log('delete', meta.id) },
],
}
function Row({ row }) {
const { ref } = useContextMenu({ ...rowMenu, meta: row }, [row])
return (
<button ref={ref} type="button">
{row.name}
</button>
)
}Compare that disabled predicate with the vanilla one above: it reads
meta.locked as the real boolean, because meta: row is stored in a WeakMap
keyed on the element and handed to predicates by identity. No data-* round
trip, no stringified numbers, and meta === row inside an action. That is the
point of the adapter.
In TypeScript, pass the element type so the ref matches the element you spread
it onto: useContextMenu<HTMLButtonElement>({ ...rowMenu, meta: row }, [row]).
The default is HTMLElement, which a <button>'s ref prop will reject.
The second argument is a normal React dependency list. The registration is
rebuilt only when one of those changes; a new inline items arrow on every
render does not churn the registry, because the definition is read through a
ref. Strict Mode's double mount leaves exactly one registration.
The hook also returns open and close, for a menu that has to open from a
click or a keyboard shortcut rather than from a right click:
const { ref, open, close } = useContextMenu({ ...rowMenu, meta: row }, [row])
return (
<button ref={ref} type="button" onClick={open}>
{row.name}
</button>
)open takes the React or native mouse event and uses its coordinates. Called
with nothing, the menu anchors to the element's own edge, which is what a
keyboard shortcut wants.
Without any further setup the hooks share one lazily created instance, so a page of a thousand rows still has a single delegated listener. Wrap a subtree in a provider when you want options, a scoped root, or two independent instances:
import { ContextMenuProvider } from '@ozankurt/context-menu/react'
function App({ children }) {
return (
<ContextMenuProvider options={{ closeOnScroll: false, offset: { x: 4, y: 4 } }}>
{children}
</ContextMenuProvider>
)
}useContextMenuInstance() returns the instance for the current subtree, which
is where you subscribe to events. It returns null during a server render and
only then, because a ContextMenu is a document listener and an element in the
top layer and a server has neither. Effects do not run on a server, so the usual
shape needs no change beyond the type:
import { useEffect } from 'react'
import { useContextMenuInstance } from '@ozankurt/context-menu/react'
function CloseLogger() {
const instance = useContextMenuInstance()
useEffect(() => instance?.on('close', (e) => console.log(e.reason)), [instance])
return null
}Vue
The directive is the primary surface, because a Vue template already has the row object in hand.
<script setup>
import { vContextMenu } from '@ozankurt/context-menu/vue'
import '@ozankurt/context-menu/styles.css'
defineProps({ row: Object })
const rowMenu = {
header: (meta) => String(meta.name),
items: [
{ label: 'Open', hint: 'Enter', action: (meta) => console.log('open', meta.id) },
{ label: 'Rename', disabled: (meta) => meta.locked },
{ type: 'separator' },
{ label: 'Delete', variant: 'danger', action: (meta) => console.log('delete', meta.id) },
],
}
</script>
<template>
<button type="button" v-context-menu="{ ...rowMenu, meta: row }">{{ row.name }}</button>
</template>Same definition as React, same identity guarantee: a reactive row reaches
predicates as that proxy, so meta.locked is a boolean and a later mutation is
visible the next time the menu opens. The directive's updated refreshes what
the registration reads rather than registering again, so an unrelated re-render
of the component does not touch the registry.
v-context-menu="rowActions" with a bare array or resolver is accepted as
shorthand for { items: rowActions }.
Register the directive globally and share one instance across the app with the plugin:
import { createApp } from 'vue'
import { ContextMenuPlugin } from '@ozankurt/context-menu/vue'
import '@ozankurt/context-menu/styles.css'
import App from './App.vue'
createApp(App).use(ContextMenuPlugin, { closeOnScroll: false }).mount('#app')The plugin's instance is destroyed with the app on Vue 3.5 and later, and it is
also available as this.$contextMenu in the Options API and through
useContextMenuInstance() in a setup. On Vue 3.3 and 3.4 there is no
app.onUnmount to hook, so the instance lives as long as the page; the call is
guarded rather than the peer range raised, so those versions keep working.
Beyond the ContextMenuOptions it forwards, the plugin takes two of its own:
| Option | Default | Meaning |
| --- | --- | --- |
| directiveName | 'context-menu' | The directive name without the v- prefix, for an app that already has a v-context-menu |
| instance | none | Share an existing ContextMenu instead of creating one for this app. A shared instance is never destroyed with the app, because it belongs to whoever passed it in |
useContextMenuInstance() returns null during a server render, for the same
reason as in React, and the directive's mounted never runs on a server at all.
For a menu that is not tied to one template element, the composable takes a target ref or a delegated selector:
import { ref } from 'vue'
import { useContextMenu } from '@ozankurt/context-menu/vue'
const row = ref(null)
const { open, close, dispose } = useContextMenu({ items: rowMenu.items }, { target: row })The second argument takes three options, and exactly one of the first two:
| Option | Meaning |
| --- | --- |
| target | The element to bind: an element, a template ref, or a getter. Resolved on mount when it is a ref |
| on | A delegated CSS selector instead, for one menu covering many elements. meta then comes from the definition and is shared by all of them |
| instance | Register on this ContextMenu rather than the injected or shared one |
It is callable outside setup() too: it then binds immediately, skips the
unmount hook, and leaves dispose to you.
jQuery
import $ from 'jquery'
import { registerJQueryPlugin } from '@ozankurt/context-menu/jquery'
import '@ozankurt/context-menu/styles.css'
registerJQueryPlugin($)
$('.row').contextMenu({
header: (meta) => String(meta.name),
items: [
{ label: 'Open', hint: 'Enter', action: (meta) => console.log('open', meta.id) },
{ label: 'Rename', disabled: (meta) => meta.locked },
{ type: 'separator' },
{ label: 'Delete', variant: 'danger', action: (meta) => console.log('delete', meta.id) },
],
})
$('.row').contextMenu('open') // or ('open', event) to use an event's coordinates
$('.row').contextMenu('destroy')registerJQueryPlugin($) is needed when jQuery is not already on the global at
import time, which is the usual case in a bundled app. When jQuery is loaded
from a <script> tag first, importing the entry installs $.fn.contextMenu by
itself. With no jQuery anywhere the module warns and returns; it never throws.
In TypeScript, registerJQueryPlugin accepts the real JQueryStatic from
@types/jquery with no cast. It works against its own minimal structural
JQueryLike internally, so the published declaration pulls in no jQuery types,
but the parameter is widened to JQueryInstallTarget because JQueryStatic
has a heavily overloaded call signature and would not otherwise be assignable.
import $ from 'jquery'
import { registerJQueryPlugin } from '@ozankurt/context-menu/jquery'
registerJQueryPlugin($)$(sel).contextMenu(...) itself is typed through a global JQuery augmentation
and needs no annotation either.
Two jQuery specific behaviours:
metacomes from$(el).data(), notel.dataset. Sodata-id="3"reaches a predicate as the number3,data-locked="true"as the booleantrue, anddata-row='{"id":7}'as an object. That is why thedisabledpredicate above is written the same way as the React one.- Every library event is already a bubbling
CustomEvent, so jQuery listeners work with no extra code:
$('.row').on('ctxmenu:open', (e) => {
console.log(e.originalEvent.detail.meta)
})config is the same definition object the other adapters take, plus one field
of its own: instance, a ContextMenu to register on instead of the shared
one. Everything else, including meta and resolveMeta, means what it means
everywhere else.
Calling .contextMenu(config) twice on the same element disposes the first
registration, so re-initialising a re-rendered list does not stack handlers.
Script tag
The IIFE build puts the core exports and registerJQueryPlugin on
window.ContextMenu. It is what unpkg and jsdelivr serve, so a page with no
build step can load it straight from a CDN:
<link rel="stylesheet" href="https://unpkg.com/@ozankurt/context-menu/dist/styles.css" />
<script src="https://unpkg.com/@ozankurt/context-menu"></script>Pin a version in production (https://unpkg.com/@ozankurt/[email protected]),
or copy the two files into your own static assets, which is what the example
below does.
<link rel="stylesheet" href="/vendor/context-menu/styles.css" />
<script src="/vendor/context-menu/context-menu.global.js"></script>
<script>
var menu = ContextMenu.createContextMenu()
menu.register({
on: '.row',
header: function (meta) {
return String(meta.name)
},
items: [
{ label: 'Open', hint: 'Enter', action: function (meta) { console.log('open', meta.id) } },
{ label: 'Rename', disabled: function (meta) { return meta.locked === 'true' } },
{ type: 'separator' },
{ label: 'Delete', variant: 'danger', action: function (meta) { console.log('delete', meta.id) } },
],
})
</script>Copy node_modules/@ozankurt/context-menu/dist/context-menu.global.js and
dist/styles.css into whatever your application serves as static assets. The
global is the module namespace, so ContextMenu.ContextMenu is the class and
ContextMenu.createContextMenu the factory. Metadata is dataset here, so
compare against strings as in the vanilla quick start.
The jQuery plugin is in this build too, which is the point of it: a server
rendered page that already has jQuery in a script tag and no bundler is exactly
the consumer the plugin was written for. Load jQuery first and importing the
build installs $.fn.contextMenu by itself; load it afterwards and call
ContextMenu.registerJQueryPlugin(jQuery) once.
<script src="/vendor/jquery.min.js"></script>
<script src="/vendor/context-menu/context-menu.global.js"></script>
<script>
$('.row').contextMenu({
items: [{ label: 'Open', action: function (meta) { console.log(meta.id) } }],
})
</script>Metadata there is $(el).data(), not dataset, exactly as in the bundled
jQuery entry.
The item model
Every item is a plain object. Fields marked "predicate" accept either a literal
value or a function (meta, ctx) => value.
How often a predicate runs. Not "exactly once per open", and the difference matters if yours has a side effect or a real cost:
hiddenis evaluated first and short circuits. An item it hides runs none of its other predicates, because an item nobody will see should not run the rest of its user code.- Every other predicate on a visible item runs once per panel that item is drawn into.
- An item with a literal
itemsarray is the exception. Deciding whether to draw the submenu arrow means resolving that array, so a child's predicates run once when the parent panel is built and again when the submenu actually opens: twice. Anitemsresolver function is taken on trust instead and is not called early, so its children run once.
| Field | Type | Predicate | What it does |
| --- | --- | --- | --- |
| label | string or (meta, ctx) => string | yes | The row's text. Always set with textContent. |
| type | 'item', 'separator', 'header', 'checkbox', 'radio', 'custom' | no | Defaults to 'item'. See Item types. |
| icon | string | no | Markup when it contains a <, otherwise a class list on the icon span. Trusted input. |
| variant | string | no | Rendered as data-variant, never as a class. Open ended. |
| class | string or string[] | no | Your own design system classes, passed through untouched. |
| hint | string | no | Right aligned hint, typically a shortcut. Always textContent. |
| disabled | boolean or predicate | yes | Keeps the row, renders it non interactive, still announces it. |
| hidden | boolean or predicate | yes | Removes the row entirely, before separators are collapsed. |
| action | (meta, ctx) => void or a promise | no | Runs after the menu has closed. A rejection becomes an error event. |
| items | Item[] or (meta, ctx) => Item[] or a promise | no | Presence makes the row a submenu. |
| id | string | no | Your own identifier, echoed back on data-ctx-item-id. |
| checked | boolean or predicate | yes | checkbox only. |
| group | string | no | radio only. The meta key the group reads. |
| value | unknown | no | radio only. Checked when meta[group] is this value. |
| render | (meta, ctx) => HTMLElement or a string | no | custom only. A string return is trusted HTML. |
ctx is { el, meta, event, close }: the element the menu opened on, the same
meta object, the trigger event, and a close(reason) you can call from an
action.
Predicates fail closed. If one throws, its item is treated as hidden and the rest of the menu renders normally, on the reasoning that an item whose guard could not be evaluated may be one the user is not allowed to act on.
Item types
menu.register({
on: '.row',
header: (meta) => String(meta.name), // the menu's own header, above everything
items: [
// A plain item. `type` may be omitted.
{ label: 'Open', hint: 'Enter', icon: 'fa fa-folder-open', action: (meta) => open(meta.id) },
// An inline SVG icon. Anything containing a `<` is inserted as markup.
{ label: 'Download', icon: '<svg viewBox="0 0 16 16"><path d="M8 1v10" /></svg>' },
// A separator. Dropped automatically when it ends up first, last or doubled.
{ type: 'separator' },
// A section heading inside the list. Not focusable, and it does not
// suppress an adjacent separator.
{ type: 'header', label: 'Visibility' },
// A checkbox. You own the state; `checked` is read on every open.
{
type: 'checkbox',
label: 'Pinned',
checked: (meta) => pinned.has(meta.id),
action: (meta) => toggle(meta.id),
},
// A radio group. There is no `checked` here: the row is checked when
// `meta[group]` equals `value`, so a group works straight off the target.
{ type: 'radio', group: 'colour', value: 'red', label: 'Red', action: () => setColour('red') },
{ type: 'radio', group: 'colour', value: 'green', label: 'Green', action: () => setColour('green') },
// A submenu. Nest as deep as you like.
{
label: 'Share',
items: [
{ label: 'Copy link', action: (meta) => copy(meta.url) },
{ label: 'Invite people', items: [{ label: 'By email' }, { label: 'By link' }] },
],
},
// Anything the item model cannot express. A returned element is appended;
// a returned string is inserted as trusted HTML.
{ type: 'custom', render: (meta) => `<p class="hint">Last opened ${meta.seen}</p>` },
{ type: 'separator' },
{ label: 'Delete', variant: 'danger', hint: 'Del', action: (meta) => remove(meta.id) },
],
})Separators collapse to a fixed point. After hidden items are removed, a separator that is first, last or immediately after another separator is dropped, and that runs repeatedly rather than once, so three consecutive separators collapse to none rather than to one. A menu whose items are mostly conditional therefore never renders a stack of stray lines.
A submenu whose children are all hidden is not a submenu. No arrow is rendered and the row cannot open an empty panel.

Async items and beforeitems
items may be a function, and it may return a promise. That is the escape hatch
for a menu whose contents come from the server.
menu.register({
on: '.row',
items: async (meta) => {
const actions = await fetch(`/api/rows/${meta.id}/actions`).then((r) => r.json())
return actions.map((a) => ({ label: a.label, action: () => run(a.id) }))
},
})A resolver that settles immediately renders nothing extra. One that actually
waits shows a loading panel first, in the right place, and swaps in the real
menu when it arrives. One that rejects shows an error panel and emits error
rather than closing: the user asked for this menu, so telling them it failed
beats making the right click look broken.

To adjust a list you did not write, or to add something at the last moment,
listen for beforeitems. The array is handed over by reference, so splicing
it changes what renders:
menu.on('beforeitems', (e) => {
if (!e.meta.canDelete) {
const index = e.items.findIndex((item) => item.label === 'Delete')
if (index !== -1) e.items.splice(index, 1)
}
e.items.push({ type: 'separator' }, { label: 'Report a problem', action: report })
})The array is a copy of the definition's own, so a listener cannot corrupt the
definition it came from. beforeitems fires before predicates are evaluated, so
anything you push is resolved like everything else, and any separator you strand
is collapsed away.
Selection aware menus
This library has no selection model, deliberately. A menu belongs to the component that was right clicked, and nothing in the core knows that other elements exist, let alone that some of them are checked.
That is not a gap to work around. Selection already lives in your application: in a DataTables instance, a React state atom, a Vue store, a set of checked checkboxes. A selection model inside the menu would be a second copy of it, and two copies disagree the moment anything changes.
The bridge is resolveMeta. It runs on every open, so it reads the selection as
it is at that instant, and whatever it returns is what every predicate, label
and action receives.
The general pattern
menu.register({
on: '.row',
resolveMeta: (el) => ({
row: rowFor(el),
selected: currentSelection(),
}),
header: (meta) =>
meta.selected.length > 1 ? `${meta.selected.length} selected` : meta.row.name,
items: [
{
label: 'Archive',
disabled: (meta) => meta.selected.some((r) => r.locked),
action: (meta) => archive(meta.selected),
},
],
})Nothing here is DataTables specific. currentSelection() can read a store, a
Set of ids, or document.querySelectorAll('.row :checked'). The menu does not
care.
Decide the right click policy first
The one question this pattern forces you to answer is what a right click on an unselected row means. There are three defensible answers and the library takes none of them for you, because applications genuinely differ:
| Policy | resolveMeta returns | Feels like |
|---|---|---|
| Selection only | the selection, ignoring the clicked row | A bulk actions bar. Right clicking an unselected row acts on the selection elsewhere on screen, which surprises people |
| Target wins | the clicked row, ignoring the selection | Predictable, but a multi select workflow becomes impossible |
| Target joins | the selection if the clicked row is in it, otherwise just the clicked row | What file managers do, and usually what people expect |
The third, written out:
resolveMeta: (el) => {
const row = rowFor(el)
const selected = currentSelection()
const inSelection = selected.some((r) => r.id === row.id)
return { row, selected: inSelection ? selected : [row] }
},Some applications also reduce the selection to the clicked row when it is
outside the selection, so the highlight matches what the menu is about to act
on. That is a UI decision, so do it in your own code inside resolveMeta; the
menu will read whatever you leave behind.
some or every
Predicates change meaning once meta carries more than one record, and the
choice is not cosmetic:
// Refuse if ANY selected row forbids it. Safe, and usually right for a
// destructive action: one locked row blocks the whole batch.
disabled: (meta) => meta.selected.some((r) => r.locked)
// Refuse only if EVERY selected row forbids it. The action then runs on a
// subset, so it must tolerate rows it cannot touch.
disabled: (meta) => meta.selected.every((r) => r.locked)Prefer some for anything destructive. A user who selects thirty rows and sees
Delete enabled will assume all thirty are going.
Keep the distinction between hidden and disabled too. An action that never
applies to this kind of row should be hidden; an action that cannot run right
now should be disabled, so the user learns it exists and why it is unavailable.
DataTables
DataTables with the Select extension is the common case, and it needs no
adapter. rows({ selected: true }) is the selection, and row(el) maps the
right clicked element back to its record:
const dt = $('#entries').DataTable()
createContextMenu().register({
on: '#entries tbody tr',
resolveMeta: (el) => {
const row = dt.row(el).data()
const selected = dt.rows({ selected: true }).data().toArray()
const inSelection = selected.some((r) => r.id === row.id)
return { row, selected: inSelection ? selected : [row] }
},
header: (meta) =>
meta.selected.length > 1 ? `${meta.selected.length} treatments selected` : meta.row.name,
items: [
{
label: 'Mark as urgent',
variant: 'danger',
icon: 'far fa-fw fa-exclamation-triangle',
hidden: (meta) => meta.selected.some((r) => r.mark_as_urgent_is_hidden),
disabled: (meta) => meta.selected.some((r) => r.mark_as_urgent_is_disabled),
action: (meta) => updateUrgent(meta.selected, 1),
},
{ type: 'separator' },
{
label: 'Mark as confirmed',
variant: 'success',
icon: 'far fa-fw fa-check',
action: (meta) => updateStatus(meta.selected, STATUS_CONFIRMED),
},
],
})Two things worth knowing, both consequences of binding by selector rather than to the table instance:
- A redraw needs no re-initialisation. Paging, sorting, filtering and Ajax
reloads all replace the
trelements, and the menu keeps working, because the registration is a selector and the listener is on the root. The old DataTables plugin had to bind to the table and rebind on redraw. - Read the table inside
resolveMeta, not outside it. Capturingdt.rows({ selected: true })at registration time freezes the selection as it was when the page loaded. The example above calls it on every open, which is the point of the hook.
If you use server side processing, row(el).data() gives you the row object the
server sent, so predicates should read the flags you already ship on it rather
than reaching back into the DOM.
Events
The same eleven events go out on two channels at once: an instance emitter and a
bubbling DOM CustomEvent named ctxmenu:<name> on the trigger element. Both
carry the same payload, and cancelling on either has exactly the same effect, so
one consumer cannot tell which channel another used.
| Event | Cancelable | Fires | detail carries |
| --- | --- | --- | --- |
| beforeopen | yes | The trigger arrived, before anything renders | el, meta, originalEvent |
| beforeitems | no | The list is resolved and mutable, before predicates run | el, meta, items, originalEvent |
| open | no | The menu is in the DOM and placed | el, meta, originalEvent |
| highlight | no | A row became the current one, by pointer or key | el, meta, item, originalEvent |
| submenu:open | yes | A submenu panel is about to be shown | el, meta, item, originalEvent |
| submenu:close | no | A submenu panel was removed | el, meta, item, originalEvent |
| select | yes | A row was chosen, before the menu closes | el, meta, item, originalEvent |
| action | no | The action ran and resolved, after the close | el, meta, item, originalEvent |
| error | no | resolveMeta, an items resolver or an action threw | el, meta, error, sometimes item |
| beforeclose | yes | A close was requested | el, meta, reason |
| close | no | The menu is gone and focus is restored | el, meta, reason |
A full open, select and close cycle fires, in this order: beforeopen,
beforeitems, open, select, beforeclose, close, action. The action
callback runs after the close on purpose. A slow action must never leave a menu
sitting in the top layer while it works, and a rejecting one must not leave it
there at all.
action reports completion, not intent. It is emitted after the action has
run and, for an async one, after its promise has resolved, so a listener can
refresh a list or clear a spinner on it. An action that rejects does not emit
action at all: its report is error. select is the event that means "a row
was chosen", and it is the cancelable one.
reason on beforeclose and close is one of select, escape, outside,
scroll, blur, api or destroy. Analytics and focus management both need
to tell a chosen action apart from an abandoned menu. blur covers two things
that mean the same to the menu: the window losing focus, and Tab, which moves
focus out of the chain deliberately.
The same subscription, on both channels:
// Instance channel. Returns an unsubscribe function.
const off = menu.on('open', (e) => console.log('opened on', e.el, e.meta))
off()// DOM channel. Bubbles, so one document listener covers every menu.
document.addEventListener('ctxmenu:open', (e) => {
console.log('opened on', e.detail.el, e.detail.meta)
})This is what makes the library usable from a framework with no adapter: Alpine
binds x-on:ctxmenu:open, Svelte binds on:ctxmenu:open, a Vue template binds
@ctxmenu:open, jQuery binds $(el).on('ctxmenu:open'). Nothing has to import
the instance.
Cancelling works with preventDefault() on either channel:
// Let the browser's own menu through over a text selection.
menu.on('beforeopen', (e) => {
if (String(window.getSelection())) e.preventDefault()
})
// Keep the menu open and skip the action.
document.addEventListener('ctxmenu:select', (e) => {
if (e.detail.item.label === 'Delete' && !window.confirm('Delete?')) e.preventDefault()
})Cancelling beforeopen is the one case where the native event is deliberately
not suppressed, so the browser's own menu appears. That is the right
behaviour over a text selection or a link.
Styling
Import the stylesheet once:
import '@ozankurt/context-menu/styles.css'Everything themeable is a custom property on .ctx-menu:
| Property | Default | Controls |
| --- | --- | --- |
| --ctx-bg | light-dark(#ffffff, #1f2023) | Panel background |
| --ctx-fg | light-dark(#16181d, #e8e9ec) | Panel text |
| --ctx-border | light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.14)) | Hairline and separators |
| --ctx-radius | 0.625rem | Panel corner radius; rows derive theirs from it |
| --ctx-shadow | a three layer shadow | Panel elevation |
| --ctx-padding | 0.25rem | Panel inner padding |
| --ctx-min-width | 12rem | Minimum panel width |
| --ctx-item-fg | var(--ctx-fg) | Row colour, and every row state is mixed out of it |
| --ctx-item-gap | 0.625rem | Gap between icon, label and hint |
| --ctx-font | a system UI stack at 0.875rem | Panel typography |
Hover, active and disabled are color-mix(in oklab, ...) derivations of
--ctx-item-fg, which is what makes a new variant a one property job. variant
is rendered as data-variant rather than as a class precisely so that the
library's styling hooks cannot collide with a design system that already ships
its own .danger:
/* A variant the library does not ship. No JS registration, no build step. */
.ctx-menu[data-variant='brand'],
.ctx-menu [data-variant='brand'] {
--ctx-item-fg: rebeccapurple;
}{ label: 'Rename', variant: 'brand' } // on an item: the .ctx-item carries it
{ on: '.row', variant: 'brand', items: [] } // on a definition: the .ctx-menu root doesBoth selectors, and that is not belt and braces. variant is accepted in
two places. An item's variant is rendered on the .ctx-item, which the
descendant selector matches. A MenuDefinition.variant is rendered on the
.ctx-menu root itself, which a descendant selector cannot reach at all, so a
rule written only that way is silently inert for a panel level variant.
danger, success and warning ship by default and are defined in exactly
this way.

The purple and the teal rows above are brand and info, which the library has
never heard of. They are two rules in the example page's own stylesheet, exactly
as written above, and adding them needed no JavaScript and no rebuild. The page
is examples/variants.html, and it loads the library from a plain <script>
tag, so there was no build step to add them to either.
Overriding. All library rules live inside @layer ctx-menu. An unlayered
rule in your own stylesheet beats every layered rule regardless of specificity,
so overriding never needs !important and never needs a specificity war:
/* Unlayered, so this wins over the library's own .ctx-item rule. */
.ctx-item {
padding-block: 0.5rem;
}If your application puts its own rules in a layer, order the layers so yours
comes last: @layer ctx-menu, app;.
Dark theme. Every default is a light-dark() pair, and the panel inherits
color-scheme from your page rather than naming its own, so it follows whatever
your page already decided. A page that writes color-scheme: light dark on its
root, which is the standard way to say it supports both, gets a panel that
follows the viewer's operating system. A page that pins itself to one scheme
gets a panel pinned with it.

To theme it yourself, override the tokens under whatever selector your app already uses:
.ctx-menu {
color-scheme: light;
--ctx-bg: #ffffff;
--ctx-fg: #16181d;
--ctx-border: rgb(0 0 0 / 0.1);
--ctx-radius: 4px;
--ctx-font: 400 0.9375rem / 1.4 'Inter', system-ui, sans-serif;
}
[data-theme='dark'] .ctx-menu {
color-scheme: dark;
--ctx-bg: #14161a;
--ctx-fg: #e9edf2;
--ctx-border: rgb(255 255 255 / 0.12);
--ctx-shadow:
0 0 0 1px var(--ctx-border),
0 16px 40px rgb(0 0 0 / 0.55);
}
[data-theme='dark'] .ctx-menu[data-variant='danger'],
[data-theme='dark'] .ctx-menu [data-variant='danger'] {
--ctx-item-fg: #ff6b5e;
}The color-scheme lines above are worth keeping when you drive the theme from
an attribute rather than from the OS. Your [data-theme] selector sets tokens,
but the panel's scrollbars, its focus ring and any light-dark() value you have
not overridden still resolve against the scheme, so naming it keeps them on the
same side as your colours. You do not need to repeat it on .ctx-menu when your
page already sets it on an ancestor: the panel inherits, and setting
color-scheme: light dark on the panel would be worse than saying nothing,
since that means "resolve me against the user's preference" rather than "follow
my page".
How far that goes: three themes on the same menu, each one a single block of custom properties, with no library rule overridden anywhere.
| Soft | Sharp | Terminal |
| --- | --- | --- |
|
|
|
|
examples/theming.html switches between them and prints the active block, read
back out of the live stylesheet so what you read is what is styling the panel in
front of you.
The whole stylesheet uses logical properties (padding-inline,
inset-inline-start), so RTL needs no second file. A
prefers-reduced-motion: reduce block disables the open transition.
Class names, all prefixed ctx-: ctx-menu, ctx-header, ctx-item,
ctx-item-icon, ctx-item-label, ctx-item-hint, ctx-item-arrow,
ctx-separator, ctx-custom, ctx-loading, ctx-error.
Placement and the top layer
The menu element carries popover="manual" and lives in the browser's top
layer. That single decision removes the three classic context menu bugs at once:
- It is not clipped. A menu inside
overflow: hidden; height: 200pxis cut off at the fold when it is a normal child. In the top layer it is not clipped at all, whatever its ancestors do. - It is not mispositioned. A
transform: translateZ(0)ancestor becomes the containing block for anyposition: fixeddescendant, so a fixed menu inside one lands at the wrong coordinates. A top layer element has no such ancestor. - It does not lose a z-index fight. There is nothing above the top layer, so a menu opened from inside a modal is on top of the modal.

That pane is a fixed height box with overflow: hidden. It clips its own last
row in half, which is what it does to everything inside it. The menu crosses the
same edge without noticing, because it is not inside it: it is in the top layer.
manual rather than auto: auto gives light dismissal for free, but its
ancestor rules close a parent menu when a non nested submenu opens. Dismissal is
implemented against the known submenu chain instead.
Placement itself is a pure function of numbers. It starts at the pointer plus
the offset, flips above the anchor when the menu would overflow the bottom edge,
flips to the other side when it would overflow the inline end edge, and clamps
back into the viewport when it still does not fit after flipping. Clamped
coordinates are never negative. A submenu passes its parent panel's rect as
an avoid, so it flanks the whole panel rather than covering it; the parent row
supplies only the vertical anchor, so the submenu still lines up with the item
that opened it. Flanking the row alone would tuck the submenu underneath its
parent, because a row is inset by the panel's own padding and border. With an
RTL trigger the preferred horizontal side is reversed.
Menus close on Escape, a pointerdown outside the menu chain, a scroll of any
ancestor (closeOnScroll, on by default), window blur, and close(). Each
carries its own reason.
On touch, a press held for longPress milliseconds (500 by default) opens the
menu, cancelled by moving more than 10px or by lifting. Pass longPress: false
to install no touch handling at all.
Keyboard and accessibility
The menu opens from the keyboard with Shift+F10 and the ContextMenu key,
where the anchor is the focused element's edge rather than a pointer position.
| Key | Does |
| --- | --- |
| ArrowDown / ArrowUp | Move one row, wrapping. Separators and headers are skipped. |
| Home / End | First / last row |
| Printable characters | Type ahead to the next row whose label starts with the buffer. The buffer clears after 500ms of no typing. |
| ArrowRight | Enter the submenu on the current row and focus its first item. Nothing on a row without one. |
| ArrowLeft | Leave the current submenu and return focus to the row that opened it |
| Enter / Space | Activate the current row |
| Escape | Close one level: the submenu if one is open, otherwise the menu |
| Tab | Close the whole chain. The close reason is blur, because focus really has left the menu |
In an RTL context ArrowLeft and ArrowRight swap, because the submenu opens
towards the inline start, which is physically the left.
Disabled rows are focusable. The arrow keys skip separators and headers but
never a disabled row: a disabled row that cannot be reached is a row a screen
reader never announces, so the user never learns why the action is unavailable.
That is also why disabled rows carry aria-disabled="true" and not the
disabled attribute.
Roles and attributes on the rendered markup:
<div class="ctx-menu" popover="manual" role="menu" id="ctx-menu-1" data-variant="compact">
<div class="ctx-header" role="presentation">Report.pdf</div>
<button class="ctx-item" role="menuitem" type="button" tabindex="-1"
aria-disabled="false" data-ctx-index="0" data-variant="danger">
<span class="ctx-item-icon" aria-hidden="true"></span>
<span class="ctx-item-label">Delete</span>
<span class="ctx-item-hint" aria-hidden="true">Del</span>
<span class="ctx-item-arrow" aria-hidden="true"></span>
</button>
<div class="ctx-separator" role="separator"></div>
</div>role="menuitemcheckbox" and role="menuitemradio" with aria-checked for
those two types, aria-haspopup="menu" plus aria-expanded on a submenu row,
with the arrow span present only there, and focus returned to the trigger when
the menu closes.
The tabindex above is what the renderer writes on every row. The keyboard
layer then owns that attribute for as long as the panel is on screen and moves
a single tabindex="0" around as you navigate, so exactly one row is the tab
stop at any moment, starting with the first.
TypeScript
Every type is exported from the root entry, and declarations ship for each entry point.
import { createContextMenu } from '@ozankurt/context-menu'
import type { Item, MenuDefinition, Meta } from '@ozankurt/context-menu'
import '@ozankurt/context-menu/styles.css'
interface RowMeta extends Meta {
id: number
name: string
locked: boolean
}
const isRow = (meta: Meta): meta is RowMeta => typeof meta['id'] === 'number'
const items: Item[] = [
{
label: (meta) => (isRow(meta) ? `Open ${meta.name}` : 'Open'),
action: (meta) => {
if (isRow(meta)) console.log(meta.id)
},
},
{ type: 'separator' },
{
label: 'Delete',
variant: 'danger',
disabled: (meta) => isRow(meta) && meta.locked,
},
]
const definition: MenuDefinition = { on: '.row', items }
createContextMenu().register(definition)Meta is Record<string, unknown> by design: resolveMeta is a hook, and the
library cannot know what it returns. Narrow it once at the boundary with a type
guard as above, or, when you own the resolveMeta, assert there instead:
const rows = new WeakMap<HTMLElement, RowMeta>()
const delegated: MenuDefinition = {
on: '.row',
resolveMeta: (el) => rows.get(el) ?? {},
items,
}With the React and Vue adapters this is simpler still, because meta is your
own object and you know its shape; the predicate signature is the only place
that widens it back to Meta.
Options reference
createContextMenu(options) and new ContextMenu(options):
| Option | Default | Meaning |
| --- | --- | --- |
| root | document | The delegation root the single listener is installed on |
| offset | { x: 2, y: 2 } | Pixels between the pointer and the panel |
| closeOnScroll | true | Close when any ancestor of the trigger scrolls |
| longPress | 500 | Touch hold in milliseconds, or false to install no touch handling |
| className | none | Extra classes added to every panel this instance renders |
| zIndexFallback | 2147483000 | Used only when the Popover API is missing |
A MenuDefinition, as passed to register:
| Field | Meaning |
| --- | --- |
| on | The delegated CSS selector. attach and the adapters supply this for you. |
| items | An Item[] or a resolver |
| resolveMeta | (el, event) => Meta. Defaults to a shallow copy of el.dataset. |
| header | A string or (meta, ctx) => string shown above the items |
| class and variant | Applied to the panel rather than to a row |
| offset | Overrides the instance offset for this menu |
| priority | Breaks a tie when two definitions match the same element |
Matching walks up from the event target and the deepest match wins, so a
.cell inside a .row, both registered, opens the cell's menu. Among
definitions matching the same element, the highest priority wins and a tie is
broken by registration order.
Instance methods: register(def), attach(el, def), open(at, def, el, event),
close(reason), destroy(), on(name, fn), and the isOpen getter.
destroy() removes the root listener, closes anything open with reason
destroy and clears the registry; calling it twice is safe.
Limitations
Read this section before adopting. These are real, and none of them is a bug that a patch release will remove.
- The Popover API is required for the top layer. Without
HTMLElement.prototype.showPopoverthe library does not throw: it removes thepopoverattribute, setsdata-fallback="true", and shows the panel as aposition: fixedelement atzIndexFallback(default2147483000). It stays usable, but it has left the top layer, so the three guarantees above degrade. A clippingoverflow: hiddenancestor can cut it off, atransformancestor can move it, and a stacking context with a higher z-index can cover it. Every current browser has the API; a very old engine or a hardened embedded webview may not. iconand acustomitem's string return are inserted as trusted HTML. Aniconcontaining a<goes in withinnerHTML, as does a string returned from acustomitem'srender.label,hint, the header and error text are alwaystextContentand are safe with any input. Never build either trusted field out of user supplied data without sanitising it first, and prefer returning anHTMLElementfromrenderwhen the content is dynamic.- There is no selection concept, and there will not be one. The library
binds to one element and hands your callbacks the metadata for that element.
A multi select workflow is possible but it is yours to wire: return the
current selection from
resolveMeta, as in the migration note below. Toolbar rendering of the same item list, drag and drop, a menu bar and any DataTables integration are out of scope too. renderLoading()has unit coverage but no browser coverage. The exported function is tested and the core path that mounts it for a slow root resolver is exercised under happy-dom, but the playground that drives the Playwright suite has no async root resolver, so no real engine has shown that panel in CI. A pending submenu deliberately mounts nothing at all, which is a different decision, not the same gap.- RTL has unit coverage only. The placement engine's RTL branch, the arrow
key swap and the logical stylesheet are all unit tested, and there is no
dir="rtl"page in the Playwright fixture. RTL has real placement consequences, so verify it yourself before shipping an RTL application. - Only one menu is open at a time per instance. A second right click closes the first menu and opens the new one. Two independent menus on screen at once needs two instances.
app.onUnmountis a Vue 3.5 API and the peer range starts at 3.3. On Vue 3.3 and 3.4 the plugin cannot hook the app teardown, so an instance it created lives as long as the page rather than being destroyed with the app. Pass your owninstanceif you need to control that yourself.- The submenu timings are not configurable. The 120ms hover open delay, the 200ms hover close delay and the 500ms type ahead buffer are constants, kept deliberately out of the public surface.
- The core reads
dataset, which stringifies. In vanilla and in the<script>build,data-locked="false"reaches a predicate as the string'false', which is truthy. Compare against strings, or supplyresolveMeta. The framework adapters do not have this problem. - One adapter signature needs a hint from a TypeScript consumer.
useContextMenuin React defaults its element type toHTMLElement, so spreading itsrefonto a<button>needsuseContextMenu<HTMLButtonElement>(...). Compile time only. - The menu renders into
document.body. That is the trigger's owner document body, so a stylesheet scoped inside a shadow root will not reach the panel; load the library's stylesheet in the document instead. - On a server there is no instance, by design. The library is a document
listener and an element in the top layer, so under
renderToString,createSSRApp, Next.js or Nuxt nothing is created:useContextMenuInstance()returnsnullin both adapters,ContextMenuProviderprovidesnull, andapp.use(ContextMenuPlugin)installs the directive without an instance behind it. Nothing throws and nothing is rendered, because there is no menu to render until a right click. The client's first render creates the real instance and everything binds on mount. What this costs you is a type: if you hold the instance outside an effect, it isContextMenu | nulland you have to say so. - Menus close on any scroll of the trigger's ancestors, including a scroll the
browser performs itself. Focusing an element below the fold scrolls the page,
and a menu opened on that element is dismissed with reason
scroll. This is the correct behaviour, since the menu is placed at a viewport point that has just gone stale, but it does surprise people who expected a menu to survive a programmaticscrollIntoView.
Migrating from datatables-contextual-actions
This library replaces the vendored dataTables.contextualActions.js plugin. The
item model survives nearly intact, because that is the part of the plugin that
was worth keeping. The row and selection model does not survive at all.
The old configuration:
{
contextMenu: { enabled: true, isMulti: true, xoffset: 10, yoffset: 10,
headerRenderer: (rows) => rows.length + ' selected',
headerIsFollowedByDivider: true, showStaticOptions: false },
items: [{ type: 'option', multi: true, title: 'Mark as urgent',
iconClass: 'fa-exclamation-triangle',
contextMenuClasses: ['text-danger'],
action: (rows) => update(rows),
isHidden: (row) => row.hidden_flag,
isDisabled: (row) => row.disabled_flag },
{ type: 'divider' }]
}The same thing here:
menu.register({
on: 'table.dataTable tbody tr',
offset: { x: 10, y: 10 },
header: (meta) => String(meta.name),
items: [
{
label: 'Mark as urgent',
icon: 'fa fa-exclamation-triangle',
class: ['text-danger'],
action: (meta) => update(meta),
hidden: (meta) => Boolean(meta.hidden_flag),
disabled: (meta) => Boolean(meta.disabled_flag),
},
{ type: 'separator' },
],
})Field by field:
| datatables-contextual-actions | Here |
| --- | --- |
| items[].title | label |
| items[].contextMenuClasses | class |
| items[].isHidden | hidden |
| items[].isDisabled | disabled |
| items[].iconClass | icon, as a class list or as inline SVG markup |
| items[].action | action |
| items[].type: 'option' | type: 'item', or omit type |
| items[].type: 'divider' | { type: 'separator' } |
| contextMenu.headerRenderer | header on the definition |
| contextMenu.xoffset and yoffset | offset: { x, y } |
| contextMenu.enabled: false | Do not register the definition |
What does not map
The old plugin was row and selection oriented. This library has no concept of either. That difference is not cosmetic, and it is where the migration work actually is:
- Callbacks received an array of selected rows.
action(rows),isHidden(row)andheaderRenderer(rows)were all fed DataTables row data. Here every callback receives onemetaobject for the one element that was right clicked, produced byresolveMeta. There is no array, and nothing in the library knows that other rows exist, let alone that some are selected. isMultianditems[].multihave no equivalent. There is no "this item works on a multi row selection" flag, because there is no selection.headerRenderer: (rows) => rows.length + ' selected'cannot be ported as written, for the same reason:headeris called with the one target's meta.headerIsFollowedByDivideris gone. The header is a distinct region with its own styling rather than a row followed by a line. Add a{ type: 'separator' }as the first item if you want the old look; it will not be collapsed away, because a header is not a separator.showStaticOptionsis gone. Every item is conditional or not by its ownhiddenpredicate; there is no separate static list.- The Bootstrap dependency is gone. The old plugin's own stylesheet was
commented out and every visual came from
.dropdown-menu, so it lost its appearance outside Bootstrap. This library ships its own stylesheet and depends on no framework. If you want the Bootstrap look back, pass those classes throughclass. - The DataTables coupling is gone. Nothing registers itself onto the
DataTables API. The menu binds to a CSS selector, so
tbody tris just a selector and a redrawn table needs no re-initialisation.
Porting a multi select workflow. Supply the selection through
resolveMeta and keep it in your own application, which is where it already
lives. Selection aware menus covers the pattern, the
three right click policies to choose between, and a full DataTables example
that replaces this plugin's isMulti behaviour.
One note specific to this plugin: its own row selection on right click was commented out in the source, so a right click acted on whatever was already checkbox selected and never on the row under the pointer. If you are matching the old behaviour exactly, that is the "selection only" policy in the table there, not the "target joins" one most applications want.
Development
npm install
npm run dev # the playground at http://localhost:5173
npm run examples # the examples at http://localhost:5175
npm test # Vitest against happy-dom
npm run test:e2e # Playwright against Chromium, Firefox and WebKit
npm run screenshots # recapture this README's images into docs/images/
npm run typecheck
npm run buildThe playground under playground/ mounts the same menu definition through all
four adapters, and doubles as the Playwright fixture. See
docs/E2E.md for the browser suite.
The examples under examples/ are the other half: presentation quality pages,
one per idea, that every image above is captured from. See
examples/README.md. They are not the playground and the
test suite does not touch them.
License
MIT, copyright 2026 Ozan Kurt. See LICENSE.
