npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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.

A file manager with a context menu open on a spreadsheet row. The menu is
headed with the file name and lists Open and Open in new tab with their keyboard
shortcuts, a greyed out Rename, Duplicate, Download, an Available offline
checkbox, a Share row with a submenu arrow, and Delete in red.

Every image in this README is captured from examples/, which you can run yourself with npm run examples. See examples/README.md.

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.locked is the string 'true' above, not the boolean. The default resolveMeta is a shallow copy of el.dataset, and dataset stringifies 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 own resolveMeta.
  • hidden and disabled are different messages. hidden removes the item; an action the user may never take should not be advertised. disabled keeps the row visible and announced, because an action the user cannot take right now should explain itself rather than vanish.

A members list with a menu open on a person whose invitation is still pending.
Send message is greyed out, Resend invitation appears only for her, a
Notifications heading is followed by a ticked Weekly digest checkbox, and a Role
heading by a radio group with Editor
selected.

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:

  • meta comes from $(el).data(), not el.dataset. So data-id="3" reaches a predicate as the number 3, data-locked="true" as the boolean true, and data-row='{"id":7}' as an object. That is why the disabled predicate 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:

  • hidden is 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 items array 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. An items resolver 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.

A mail client with three menu panels cascading to the right: the message menu,
its Move to submenu listing Inbox, Archive, Spam, Projects and Teams, and the
Projects submenu listing Northwind, Contoso, Fabrikam and New
project.

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.

The same mail client with an Add label submenu open, listing five labels as
checkboxes with Needs reply and Design ticked. The list arrived from a request
after the parent menu was already on
screen.

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 tr elements, 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. Capturing dt.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 does

Both 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.

A deployments list with a menu open. Promote to production is green, Re-run
failed jobs amber, Pin to release channel purple, Copy build metadata teal and
Delete deployment red. The purple and teal ones are variants the library does not
ship.

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.

The same file manager and the same menu in dark mode. The panel is near black
with light text, the hairline and separators are pale, and Delete is a lighter
red than in light mode.

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 | | --- | --- | --- | | A context menu with 16px rounded corners, generous padding and a system font, over a track listing. | The same menu with 2px corners, tighter rows that run edge to edge, a smaller font and a harder border. | The same menu with square corners, a monospace font, dark green text on a cream panel and a solid green border. |

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: 200px is 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 any position: fixed descendant, 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.

A Shared with you pane labelled overflow: hidden. The pane cuts its own last
row in half at its bottom edge, while the context menu opened inside it carries
straight on across that edge and over the interface
below.

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.showPopover the library does not throw: it removes the popover attribute, sets data-fallback="true", and shows the panel as a position: fixed element at zIndexFallback (default 2147483000). It stays usable, but it has left the top layer, so the three guarantees above degrade. A clipping overflow: hidden ancestor can cut it off, a transform ancestor 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.
  • icon and a custom item's string return are inserted as trusted HTML. An icon containing a < goes in with innerHTML, as does a string returned from a custom item's render. label, hint, the header and error text are always textContent and are safe with any input. Never build either trusted field out of user supplied data without sanitising it first, and prefer returning an HTMLElement from render when 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.onUnmount is 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 own instance if 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 supply resolveMeta. The framework adapters do not have this problem.
  • One adapter signature needs a hint from a TypeScript consumer. useContextMenu in React defaults its element type to HTMLElement, so spreading its ref onto a <button> needs useContextMenu<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() returns null in both adapters, ContextMenuProvider provides null, and app.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 is ContextMenu | null and 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 programmatic scrollIntoView.

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) and headerRenderer(rows) were all fed DataTables row data. Here every callback receives one meta object for the one element that was right clicked, produced by resolveMeta. There is no array, and nothing in the library knows that other rows exist, let alone that some are selected.
  • isMulti and items[].multi have 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: header is called with the one target's meta.
  • headerIsFollowedByDivider is 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.
  • showStaticOptions is gone. Every item is conditional or not by its own hidden predicate; 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 through class.
  • The DataTables coupling is gone. Nothing registers itself onto the DataTables API. The menu binds to a CSS selector, so tbody tr is 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 build

The 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.