@get-set/gs-contextmenu
v1.0.2
Published
Get-Set Context Menu
Maintainers
Readme
GSContextMenu
A dependency-free, feature-rich context menu / dropdown menu available in two flavours from one codebase:
- Native / vanilla JS — a
window.GSContextMenu(selector, params)factory plus awindow.GSContextMenuConfigueinstance registry. jQuery andHTMLElement.prototypeadapters are wired automatically. - React — a
<GSContextMenu />component with an imperative ref handle.
Both share the exact same engine (actions/, constants/, helpers/, types/), so behaviour is identical across the two.
Features
- Four ways to use it — vanilla
window.GSContextMenu, jQuery$(...).GSContextMenu(),element.GSContextMenu(), and the React<GSContextMenu>component. - Trigger modes —
contextmenu(right-click, default),click(left-click dropdown),both, ormanual(imperative API only). - Cursor-anchored positioning — opens toward the bottom-right of the pointer and flips up / left when it would overflow the bottom / right edge, then shifts into view. Anchored (dropdown) mode uses a full placement resolver with auto-flip + shift.
- Nested submenus — static arrays or dynamic
children(ctx)functions, opened on hover (with configurable open/close delays) or by keyboard. - Rich item types —
item,checkbox,radio(with sibling grouping),separator(or the'-'shorthand), and sectionheaders. - Item extras — leading icon, right-aligned
shortcuthint,dangerstyling,disabled,href/targetlinks,keepOpen, and per-itemclassName. - Full keyboard support — Arrow navigation, ArrowRight/ArrowLeft to open/close submenus (RTL-aware), Home/End, Enter/Space to activate, Escape (closes the deepest level first), Tab to dismiss, and type-ahead.
- Dismissal controls — close on outside click, Escape, window blur, scroll, or after selecting.
- Animations — 9 named entrance/exit animations (
fade,scale/zoom, fourslide-*,flip,perspective) +none, with tunable duration/easing andprefers-reduced-motionsupport. - Theming —
light/dark/auto(follows OS), a customaccentColortoken, size and density presets, elevation, corner radius, min/max width, max height (scrolling list), and an optionaldim/blurbackdrop. - RTL — right-to-left layout with inverted submenu / flip preferences.
- Accessibility —
role="menu"withmenuitem/menuitemcheckbox/menuitemradio/separatorroles,aria-haspopup/aria-expanded/aria-checked/aria-disabled, roving focus, and focus restore on close. - React-only
gsxprop — scoped, per-instance inline styles injected into<head>and cleaned up on unmount.
Installation
npm i @get-set/gs-contextmenuCompatibility
| Target | Requirement |
|---|---|
| React (the <GSContextMenu> component) | React 16.8+ (Hooks are required), and 17 / 18 / 19. React is an optional peer dependency — you only need it for the component. |
| Native / vanilla (window.GSContextMenu) | No framework. Any modern evergreen browser. |
| jQuery | Optional — the $.fn.GSContextMenu adapter is registered only when window.jQuery is present when the bundle loads. |
| TypeScript | First-class — type declarations (.d.ts) ship in the package. |
| SSR / Next.js | Safe to import server-side (no DOM access at module load). Render the component inside a Client Component ('use client'). |
The peer range is
^16.8.0 || ^17.0 || ^18.0 || ^19.0, withreact/react-dommarked optional so the native build has zero peer dependencies.
Package entry points
From package.json:
| Field | Value |
|---|---|
| name | @get-set/gs-contextmenu |
| main | dist/components/GSContextMenu.js |
| module | dist/components/GSContextMenu.js |
| types | dist/components/GSContextMenu.d.ts |
| exports['.'].import | ./dist/components/GSContextMenu.js (React/ESM) |
| exports['.'].require | ./dist-js/bundle.js (native window-global bundle) |
| files | dist, dist-js, styles, example.html |
Project layout
GSContextMenu.ts # native entry (webpack -> dist-js/bundle.js, window global + adapters)
components/GSContextMenu.tsx # React component (tsc -> dist/, npm entry)
actions/ # shared engine (init/open/close/refresh/destroy/getCurrentParams/animate)
constants/ # animations, placements, defaultParams
helpers/ # items, keyboard, layout, position, render, uihelpers
types/ # Params / Ref / Window augmentation
components/styles/ # SCSS + compiled CSS + CSS-as-TS (runtime injection for React)
styles/ # SCSS + compiled CSS (for <link> use by the native build)Build
npm install
npm run build # builds both targets
npm run build:js # native bundle -> dist-js/bundle.js
npm run build:react # React + types -> dist/Tests
Unit tests use Vitest + jsdom:
npm test # run once
npm run test:watchCoverage spans the pure logic (param resolution, item normalization, placement math, positioning flip/clamp, animation resolution), the native DOM target (factory, registry, right-click open, item/checkbox/radio activation, submenus, dismissal, theming), and the React component (rendering, imperative ref, activation, submenus, keyboard nav + type-ahead, prop-stripping, gsx inject/cleanup, registry register/unregister). Note: jsdom has no layout engine, so pixel-level positioning is validated via the math helpers rather than rendered geometry.
Usage — 1. Native / vanilla JS
Link the stylesheet, load the bundle, then call the factory with a selector or an element:
<link rel="stylesheet" href="node_modules/@get-set/gs-contextmenu/styles/GSContextMenu.css" />
<script src="node_modules/@get-set/gs-contextmenu/dist-js/bundle.js"></script>
<div id="area">Right-click me</div>
<script>
window.GSContextMenu('#area', {
reference: 'area-menu',
theme: 'dark',
items: [
{ label: 'Cut', shortcut: 'Ctrl+X', onClick: (item, ctx) => console.log('cut', ctx.target) },
{ label: 'Copy', shortcut: 'Ctrl+C' },
'-',
{
label: 'Share',
children: [{ label: 'Email' }, { label: 'Messages' }]
},
{ header: 'View' },
{ label: 'Word wrap', type: 'checkbox', checked: true, keepOpen: true },
{ label: 'Delete', danger: true }
]
});
</script>The factory accepts either a CSS selector string or a raw HTMLElement (the jQuery + prototype adapters both pass a raw element here). It returns the instance Ref, or undefined if the element was not found or the reference is already taken.
The instance registry — window.GSContextMenuConfigue
Every instance registers itself under its reference (a supplied string or an auto-generated UUID). Look one up later without keeping the return value around:
const menu = window.GSContextMenuConfigue.instance('area-menu');
menu.openMenu(200, 120); // open at viewport coords
menu.setItems([{ label: 'Fresh' }]);
menu.closeMenu();
menu.destroy(); // tears down + unregistersUsage — 2. jQuery
When window.jQuery is present as the bundle loads, a $.fn.GSContextMenu adapter is registered. It constructs one instance per matched element:
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="node_modules/@get-set/gs-contextmenu/dist-js/bundle.js"></script>
<script>
$('.has-menu').GSContextMenu({
trigger: 'contextmenu',
items: [{ label: 'Edit' }, { label: 'Remove', danger: true }]
});
</script>Usage — 3. HTMLElement.prototype
The bundle also adds a GSContextMenu method to HTMLElement.prototype, so any element can bind a menu to itself:
document.getElementById('area').GSContextMenu({
items: [{ label: 'Rename' }, { label: 'Duplicate' }]
});Usage — 4. React <GSContextMenu>
Wrap the trigger area in <GSContextMenu> and pass the items array. The menu portals into document.body while open:
'use client';
import { useRef } from 'react';
import GSContextMenu, { GSContextMenuHandle } from '@get-set/gs-contextmenu';
export default function Demo() {
const ref = useRef<GSContextMenuHandle>(null);
return (
<GSContextMenu
ref={ref}
theme="dark"
items={[
{ label: 'Cut', shortcut: 'Ctrl+X', onClick: (item, ctx) => console.log(item, ctx) },
{ label: 'Copy', shortcut: 'Ctrl+C' },
'-',
{ label: 'Share', children: [{ label: 'Email' }, { label: 'Messages' }] },
{ header: 'View' },
{ label: 'Word wrap', type: 'checkbox', checked: true, keepOpen: true },
{ label: 'Delete', danger: true }
]}
onSelect={(item) => console.log('selected', item.label)}>
<div className="canvas">Right-click me</div>
</GSContextMenu>
);
}The React target imports its CSS itself (injected once per document), so no <link> is required.
Controlled vs. uncontrolled
- Uncontrolled (default): the component owns its open state. Drive it with the imperative ref (
ref.current.open(x, y)) or the trigger. UsedefaultOpento start open. - Controlled: pass
open(boolean) plusonOpenChange(next)and own the state yourself.
The gsx prop (React only)
gsx accepts a (nestable) CSS map that is scoped to the instance via a data-key attribute, injected into <head> on mount and removed on unmount:
<GSContextMenu
gsx={{ '.gs-contextmenu': { borderRadius: '12px', minWidth: '240px' } }}
items={items}>
<div>Right-click me</div>
</GSContextMenu>Options / Props
These apply to both targets. In native they are keys on the second params argument; in React they are props on <GSContextMenu>. Defaults come from constants/defaultParams.ts.
| Option | Type | Default | Description |
|---|---|---|---|
| reference | string | auto UUID | Stable id used for the registry + gsx scoping. |
| items | GSMenuEntry[] | [] | The menu entries (see the schema below). |
| trigger | 'contextmenu' \| 'click' \| 'both' \| 'manual' | 'contextmenu' | How the menu opens. manual wires no listeners (API only). |
| preventDefault | boolean | true | Prevent the native browser context menu on contextmenu triggers. |
| placement | GSContextMenuPlacement | 'bottom-start' | Anchored placement in click/dropdown mode. |
| offset | { x?: number; y?: number } | – | Custom nudge from the resolved position. |
| gap | number | 4 | Distance (px) between anchor and menu in anchored mode. |
| flip | boolean | true | Auto-flip to the opposite side on viewport collision (anchored mode). |
| shift | boolean | true | Shift the menu along the cross axis to stay on screen. |
| boundaryPadding | number | 8 | Viewport padding kept when flipping / shifting (px). |
| submenuOpenDelay | number | 120 | Delay (ms) before a hovered submenu opens. |
| submenuCloseDelay | number | 200 | Delay (ms) before a submenu closes on mouse-out. |
| closeOnOutside | boolean | true | Close on outside pointerdown. |
| closeOnEsc | boolean | true | Close on Escape. |
| closeOnBlur | boolean | true | Close when the window loses focus. |
| closeOnScroll | boolean | false | Close when the page scrolls. |
| closeOnSelect | boolean | true | Close after selecting an item (unless the item sets keepOpen). |
| animation | GSContextMenuAnimation \| false | 'scale' | Entrance animation. false disables motion. |
| exitAnimation | GSContextMenuAnimation \| false | mirrors animation | Separate exit animation. |
| animationDuration | number \| { enter?: number; exit?: number } | 160 | Animation duration in ms. |
| animationEasing | string | cubic-bezier(.16,1,.3,1) | CSS easing for the animation. |
| reducedMotion | 'auto' \| boolean | 'auto' | Honor prefers-reduced-motion. |
| theme | 'light' \| 'dark' \| 'auto' | 'auto' | Visual theme. auto follows prefers-color-scheme. |
| accentColor | string | – | Accent color (CSS var --gscm-accent) for highlight / focus. |
| rtl | boolean | false | Right-to-left layout. |
| size | 'sm' \| 'md' \| 'lg' | 'md' | Size preset. |
| density | 'comfortable' \| 'compact' | 'comfortable' | Item density. |
| minWidth | number \| string | 200 | Minimum menu width (number = px). |
| maxWidth | number \| string | – | Maximum menu width. |
| maxHeight | number \| string | – | Max menu height; the list scrolls past this. |
| rounded | number \| string | – | Corner radius. |
| elevation | number | 3 | Shadow elevation preset (0–5). |
| backdrop | 'none' \| 'dim' \| 'blur' | 'none' | Full-screen backdrop behind the menu. |
| className | string | – | Extra class name on the menu root. |
| onOpen | () => void | – | Fired after the menu opens. |
| onClose | () => void | – | Fired after the menu closes. |
| onSelect | (item) => void | – | Fired when an item is selected (activated). |
| onItemClick | (item, event) => void | – | Fired when an item is clicked, before close. |
React-only props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | – | Controlled open state. Pair with onOpenChange. |
| defaultOpen | boolean | false | Uncontrolled initial open state. |
| onOpenChange | (open: boolean) => void | – | Fired when the open state should change (controlled pattern). |
| gsx | NestedCSS | – | Scoped inline styles injected for this instance. |
| children | ReactNode | – | The trigger area. |
placementaccepts any of:top,top-start,top-end,bottom,bottom-start,bottom-end,left,left-start,left-end,right,right-start,right-end.animationaccepts:fade,scale,zoom(alias ofscale),slide-up,slide-down,slide-left,slide-right,flip,perspective,none.
The GSMenuItem schema
Each entry in items is either the '-' separator shorthand or a GSMenuItem object:
| Field | Type | Description |
|---|---|---|
| id | string | Stable id (radio grouping fallback + keys). |
| label | string | Visible label. |
| icon | string \| ReactNode | Leading icon — an HTML string (native) or a ReactNode (React). |
| shortcut | string | Right-aligned shortcut hint, e.g. 'Ctrl+C'. |
| disabled | boolean | Non-interactive, dimmed. |
| danger | boolean | Destructive (red) styling. |
| type | 'item' \| 'checkbox' \| 'radio' \| 'separator' \| 'header' | Entry kind. Default 'item'. |
| checked | boolean | Checkbox / radio checked state. |
| name | string | Radio group name (siblings sharing it uncheck on select). |
| header | string | Non-interactive header label (alias for { type: 'header', label }). |
| separator | boolean | Divider shorthand ({ separator: true }). |
| href | string | Anchor href — renders the item as a link. |
| target | string | Anchor target (with href). |
| onClick | (item, ctx) => void | Click handler. |
| action | (item, ctx) => void | Alias of onClick. |
| children | GSMenuItem[] \| ((ctx) => GSMenuItem[]) | Submenu entries (static array or dynamic function). |
| items | GSMenuItem[] \| ((ctx) => GSMenuItem[]) | Alias of children. |
| keepOpen | boolean | Keep the menu open after activating this item. |
| className | string | Extra class name on the item element. |
The ctx (GSMenuContext) passed to onClick / dynamic children is:
| Field | Type | Description |
|---|---|---|
| target | HTMLElement \| null | The element the menu is bound to (the trigger area). |
| x / y | number | The pointer coordinates the menu opened at (viewport space). |
| close | () => void | Close the whole menu programmatically. |
Shorthands: '-' and { separator: true } both normalize to a separator; { header: 'Section' } normalizes to a header.
Imperative API / ref methods
Native — methods on the returned Ref (also available via window.GSContextMenuConfigue.instance(ref)):
| Method | Description |
|---|---|
| openMenu(x?, y?) / open_(x?, y?) | Open at explicit viewport coordinates (default 0, 0). |
| openAt(el) | Open anchored to an element (uses placement). |
| closeMenu() | Close the menu. |
| toggle() | Toggle the open state (re-opens at the last coordinates). |
| isOpen() | Whether the menu is currently open. |
| setItems(items) | Replace the item list (refreshes if open). |
| refresh() | Re-render the open menu. |
| destroy() | Tear down + unregister from the registry. |
React — the GSContextMenuHandle exposed via ref:
| Method | Description |
|---|---|
| open(x?, y?) | Open at explicit viewport coordinates (default 0, 0). |
| openAt(el) | Open anchored to an element. |
| close() | Close the menu. |
| toggle() | Toggle the open state. |
| isOpen() | Whether the menu is currently open. |
| setItems(items) | Replace the item list. |
| refresh() | Re-measure + reposition the open menu. |
| destroy() | Tear down (close) the menu. |
Events / callbacks
| Callback | Fires |
|---|---|
| onOpen() | After the menu opens. |
| onClose() | After the menu closes (native: after the exit animation). |
| onSelect(item) | When an item is selected (activated). |
| onItemClick(item, event) | When an item is clicked, before close. |
| onOpenChange(open) | (React, controlled only) When the open state should change. |
| item onClick(item, ctx) / action(item, ctx) | When that specific item is activated. |
Keyboard shortcuts
Available while the menu is open (LTR; ArrowLeft / ArrowRight swap under rtl):
| Key | Action |
|---|---|
| ArrowDown / ArrowUp | Move the highlight within the current level (skips separators / headers / disabled, wraps). |
| Home / End | Highlight the first / last interactive item. |
| ArrowRight | Open the highlighted item's submenu and focus its first item. |
| ArrowLeft | Close the current submenu, returning to the parent level. |
| Enter / Space | Activate the highlighted item (or open its submenu). |
| Escape | Close the deepest open submenu first; at the root level, close the whole menu. |
| Tab | Dismiss the menu. |
| type a letter | Type-ahead — jump to the next item whose label starts with the typed buffer (buffer clears after 500 ms). |
Accessibility
- The menu root is
role="menu"withtabindex="-1"; items userole="menuitem",role="menuitemcheckbox",role="menuitemradio", orrole="separator"; headers arerole="presentation". - Submenu parents expose
aria-haspopup="menu"andaria-expanded; checkbox / radio items exposearia-checked; disabled items exposearia-disabled. - Focus moves into the menu on open and is restored to the previously focused element (usually the trigger) on close.
- The roving highlight (arrow / type-ahead navigation) is conveyed to assistive technology: the native target moves real DOM focus onto the active item element, while the React target keeps focus on the menu container and points its
aria-activedescendantat the active item'sid(eachmenuitemcarries a stable id). With an open submenu, focus follows to the deepest menu container so its active descendant is the one announced. - Motion honors
prefers-reduced-motionwhenreducedMotionis'auto'(the default).
Theming & RTL
- Themes —
theme="light" | "dark" | "auto"add ags-contextmenu-theme-*class;autofollowsprefers-color-scheme. - Accent —
accentColorsets the--gscm-accentCSS variable used for the active/focus highlight. - Sizing —
size(sm/md/lg) anddensity(comfortable/compact) are class presets;minWidth/maxWidth/maxHeight/roundedbecome--gscm-min-width/--gscm-max-width/--gscm-max-height/--gscm-radiusvariables;elevation(0–5) adds ags-contextmenu-elev-*class. - Backdrop —
backdrop="dim" | "blur"renders a full-screen layer behind the menu. - RTL —
rtladdsgs-contextmenu-rtland inverts submenu / cursor-flip preferences (submenus open leftward, the root flips left by default). - Custom styles — target the public classes (
.gs-contextmenu,.gs-contextmenu-item,.gs-contextmenu-item-active,.gs-contextmenu-separator,.gs-contextmenu-header,.gs-contextmenu-item-shortcut,.gs-contextmenu-item-danger, …) in your own CSS, or (React) via the scopedgsxprop.
Examples
Native — dropdown with a dynamic submenu
window.GSContextMenu('#menuBtn', {
trigger: 'click',
placement: 'bottom-start',
theme: 'dark',
items: [
{ label: 'Profile' },
{ label: 'Recent', children: (ctx) => loadRecent(ctx.target).map((r) => ({ label: r })) },
'-',
{ label: 'Sign out', danger: true }
]
});React — checkbox + radio group, kept open
<GSContextMenu
closeOnSelect={false}
items={[
{ header: 'View' },
{ label: 'Word wrap', type: 'checkbox', checked: true, keepOpen: true },
'-',
{ header: 'Font size' },
{ label: 'Small', type: 'radio', name: 'size', keepOpen: true },
{ label: 'Medium', type: 'radio', name: 'size', checked: true, keepOpen: true },
{ label: 'Large', type: 'radio', name: 'size', keepOpen: true }
]}>
<div className="editor">Right-click me</div>
</GSContextMenu>A complete, runnable native demo lives in example.html — open it after npm run build.
License
ISC © Get-Set
