ui-013

Right-Click Context Menu

£10 or the whole Vault, £99

A right-click context menu for lists and tables: per-row items from data, typeahead and submenus, springing out of the corner nearest the pointer.

intermediate
6 more details
micro-interactionspring-physicsstaggerkeyboard-navigationpointer-trackingtouch-interaction

This element reads better at your own screen size than in the frame below:

Updated

About this component

A right-click context menu for file lists, tables and canvases. One menu serves every target on the page: its rows are built from data for the row that was clicked, with the row's name in a header, checkable rows that reflect its state, disabled and destructive rows, and nested submenus. It opens where the pointer is and springs out of the corner nearest it, clamps itself inside the viewport, and slides one highlight between its rows.

Read the full effect overview

It is built to be dropped into a product: Shift+F10, the ContextMenu key and a long-press all open it, typing letters jumps to matching rows, a submenu survives diagonal travel towards it and flips left when there is no room, and focus returns to the row on close. Mounted with one call per scope, it reports selections as events and destroys cleanly. Vanilla JavaScript and core GSAP, in a light and a dark theme.

Right-Click Context Menu - UI component preview

What's included

10 items
  • One menu for every target: ContextMenu.mount(scope, { targets, items }) builds the rows per target on each open
  • Items from data: icons, shortcuts, disabled, destructive and checkable rows, separators and nested submenus, each with run(target)
  • A header naming the target, which is also the menu's accessible name
  • Typeahead with a short buffer; repeating a letter cycles through its matches
  • Submenu hover-grace: a safe triangle keeps the submenu open on diagonal travel, and it flips left when the right has no room
  • Opens on right-click, Shift+F10 or the ContextMenu key on a focused target, and a 480ms long-press on touch
  • Spring pop from the corner nearest the cursor, clamped inside the viewport on both axes
  • role=menu, menuitem and menuitemcheckbox, arrows, Home/End, Escape and Tab, with focus returned to the target
  • contextmenu:open, :select and :close events, multiple instances and a destroy() that removes everything it added
  • Light and dark themes on one token list, set with a single data-variant attribute

Perfect for

4 use cases
  • File and asset managers where every row needs the same actions
  • Canvas and editor tools that replace the browser menu
  • Dashboards and data tables with per-row operations
  • Any app that wants a native-feeling menu without a component library

How it works

3 sections

Spring from the pointer

On open the items are resolved for the target and rendered into the one menu element, which is unhidden and sized from layout values the tween cannot distort. Whether it has to flip on each axis decides both its clamped position and the corner it grows from, then it scales up from just over half size with a spring.

Submenus that forgive diagonal travel

Each submenu nests inside its parent panel at the parent row's offsetTop and opens to whichever side has room. While one is open, every pointer move over the parent is tested against a triangle from the previous pointer position to the submenu's near corners; inside it the row under the pointer is not committed, so crossing other rows on the way does not close it.

One focus state for pointer and keyboard

Hovering a row focuses it, and focus moves a single sliding highlight measured from the row's offsets. Arrow keys, Home, End and typeahead all move that same focus, skipping disabled rows, and closing returns focus to the target that opened the menu.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Right-Click Context Menu opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page, a six-row file list wired through the public API
  • assets/style.css: component styles, plain CSS custom properties for colours
  • assets/script.js: readable, commented source; one global, window.ContextMenu

What the component does:

  • Many targets, one menu. ContextMenu.mount(scope, { targets, items }) listens on the scope; every element matching targets gets its own menu, built from items(target) on each open
  • Data-driven items: icons, shortcuts, disabled rows, destructive rows, checkable rows (menuitemcheckbox), separators and nested submenus, each with its own run(target)
  • A header naming the target (the title option), which is also the menu's accessible name
  • Typeahead: typing letters jumps to the next row starting with them, with a 500ms buffer; repeating one letter cycles through its matches
  • Submenu hover-grace: while a submenu is open, moving towards it across other rows does not close it (a safe triangle from the pointer to the submenu's near edge), and a submenu with no room on the right flips to the left
  • Every way in: right-click, a 480ms long-press on touch, and Shift+F10 or the ContextMenu key on a focused target
  • Springs from the corner nearest the cursor, clamped inside the viewport on both axes; rows stagger in and one highlight slides between them
  • Focus returns to the target on close; contextmenu:open, contextmenu:select and contextmenu:close events on the scope
  • Any number of instances per page; destroy() removes every listener and every element the menu added

Quick Start

1. Add to your <head>:

Code snippet omitted: it ships with the download.

2. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

3. Mark your targets and mount:

Code snippet omitted: it ships with the download.

Wrap the mount in DOMContentLoaded (or put it in a script after assets/script.js): the component script defines window.ContextMenu immediately, but your targets have to exist.

The menu element is created by the script and appended to document.body (or the container you pass), so there is no menu markup to copy. icon: 'i-open' refers to a <symbol id="i-open"> in an SVG sprite on the page; copy the sprite from the top of index.html, or pass your own SVG markup as the icon.

Give each target tabindex="0" (or use an element that is focusable already) so the keyboard path works, and describe the shortcut to screen readers once, as the demo does with aria-describedby.

Demo furniture. The toolbar strip (theme toggle, "Open menu"), the .stage, .stage-files, .stage-file rows, the pin glyph and the status line are the demo page, not the component. assets/script.js never reads a .stage-* class. Delete them in your own page.

Upgrading from the id-based version

Earlier versions found a hand-written #ctxMenu block by id and opened it anywhere on the page. That markup still works unchanged: if the page has a #ctxMenu element and nothing mounts it, the script mounts it page-wide on load, reading its rows (icons, hints, is-danger, data-submenu) into the same item objects. To mount it yourself instead, pass it as menu:

Code snippet omitted: it ships with the download.

Put data-context-menu-manual on the block to turn the automatic mount off. New pages should use items, which is what makes per-target menus possible.

API

ContextMenu.mount(scope, options)

Returns an instance, or the existing one if scope is already mounted.

Option Default What it does
targets null CSS selector for the targets inside scope. null makes the scope itself the only target
items null An array of items, or (target) => items, called on every open
title null (target) => string (or a string): a header above the rows, and the menu's accessible name
label 'Actions' Accessible name when there is no title
longPress true Open on a 480ms touch press
container document.body Where the menu element is appended. It must sit inside whatever carries data-variant
menu null An existing .ctx-menu element to adopt (the pre-mount markup); restored exactly on destroy()

A right-click on anything that is not a target is left alone, so the browser's own menu still appears there. A target whose items resolves to an empty list does not open either.

Items

Code snippet omitted: it ships with the download.

A separator is '-', 'separator' or { type: 'separator' }. Separators at either end or next to each other are dropped, so filtering items out never leaves a stray rule. When any row in a panel has an icon or a check, every row gets the icon column, so labels stay aligned.

A checkable row's checked is the current state; the menu does not store it. Update your own data in run, and the next open's items(target) reflects it.

Instance

Member What it does
openAt(x, y, target?, { focus }) Opens at viewport coordinates for target (default: the scope). focus: 'first' lands on the first enabled row; the default 'menu' focuses the menu with nothing highlighted
close({ restoreFocus, instant }) Closes; focus returns unless restoreFocus: false
destroy() Closes, removes every listener, reverts every tween and removes the menu element (or restores an adopted one)
isOpen, target Current state
element The menu element

ContextMenu.get(scope) returns the instance mounted on a scope, or null.

Events

Dispatched on the scope element; they bubble.

Event detail
contextmenu:open { target }
contextmenu:select { id, label, checked, target, item }. Cancelable: preventDefault() skips the item's run
contextmenu:close { target }

One contextmenu:select listener can handle every item that has no run, which is how the demo reports Open, Duplicate and Delete.

While the menu is open its target carries data-context-open, for a pressed-row style (the demo tints the row).

Keyboard & Accessibility

Key What it does
Shift+F10 / ContextMenu On a focused target, opens the menu below it with the first row focused
ArrowDown / ArrowUp Next / previous enabled row, wrapping; from a pointer open, the first / last
Home / End First / last enabled row
ArrowRight On a row with a submenu, opens it and focuses its first enabled row
ArrowLeft Inside a submenu, closes it and returns to its row
Enter / Space Runs the focused row, toggles a checkable one, or opens a submenu
Letters Typeahead: jumps to the next row starting with what was typed in the last 500ms
Escape Closes the innermost submenu, then the menu, returning focus to the target
Tab Closes the menu and returns focus to the target
  • Each panel's list is role="menu", named by the title (or label) and, for a submenu, by its parent row. Rows are <button role="menuitem">, or role="menuitemcheckbox" with aria-checked; separators are role="separator"; list items are role="none".
  • Disabled rows carry aria-disabled="true" rather than disabled, so they are announced but never run.
  • Submenu rows carry aria-haspopup="menu", aria-expanded and, while open, aria-controls.
  • Rows use a roving focus (tabindex="-1"): hovering a row focuses it, so the pointer and keyboard share one state and one sliding highlight.
  • The header is aria-hidden; its text is already the menu's name. Shortcut hints are aria-hidden too.
  • Focus returns to whatever was focused when the menu opened (the target, when it is focusable).
  • prefers-reduced-motion: reduce sets every duration to zero, read at each open: the menu appears, moves and closes instantly rather than not at all.

How It Works

Spring from the pointer. On open the item list is resolved for the target and rendered into the one menu element, which is then unhidden and measured with offsetWidth / offsetHeight, layout values the tween cannot distort. Whether the panel has to flip on each axis decides both its clamped position and its transformOrigin, then one gsap.fromTo scales it from 0.55 on a back.out(2.6) ease so it grows out of the corner under the cursor. Every tween runs inside a gsap.context that is reverted on the next open and on destroy().

One sliding highlight per panel. The highlight keeps left: 0 and top: 0 in CSS and takes its whole box from the focused row's offsets summed up to its panel, so nothing doubles up with a CSS inset. It fades in on the first row and tweens x, y, width and height after that.

Submenu placement. Each submenu is nested inside its parent panel, at the parent row's offsetTop. The script tracks every panel's viewport position in CSS pixels, opens to the right when the submenu fits, to the left when only the left fits, and to the roomier side (clamped) when neither does, as on a phone.

Hover-grace. While a submenu is open, each pointer move over its parent is tested against a triangle from the previous pointer position to the submenu's near top and bottom corners. Inside it, the pointer is heading for the submenu, so the row under it is not committed; outside it, or after 300ms at rest, it is. Arriving in the submenu clears the wait.

Typeahead. Printable keys append to a buffer that clears 500ms after the last key; the search starts after the focused row for a single (or repeated) letter and at the focused row for a longer string, skipping disabled rows.

Themes

The menu ships in two themes, light and dark, set with data-variant on <body>. The demo toggles it there, so the ground flips with the component:

Code snippet omitted: it ships with the download.

Same markup, same script: nothing in assets/script.js reads the variant name. The demo's toolbar toggle and the ?variant=light URL parameter only set that attribute.

Both blocks in assets/style.css define the same list, so re-branding is editing values in one place:

Token What it colours
--accent, --accent-ink The focus ring, ::selection, and the flash a row gives when it is chosen
--menu-bg, --menu-border, --menu-radius, --menu-shadow, --menu-pad The panel itself
--item-fg, --item-hover-fg, --item-hover-bg, --item-radius The rows and the sliding highlight
--glyph-fg, --hint-fg, --sep-color Icons, shortcut hints, the header, disabled rows, the rule
--danger-fg Destructive rows
--sub-bg, --sub-border Submenus
--ground, --raised, --hover, --ink, --ink-2, --ink-3, --line The demo ground and file list (furniture)

To carry your own brand, copy either body[data-variant="..."] block, keep the names and change the values. The row flash reads --accent and --item-hover-bg with getComputedStyle at tween time, so a re-valued theme flashes its own colour with no script change.

Customisation

What Where
Viewport clamp margin EDGE in assets/script.js (default 10px)
Long-press delay LONG_PRESS_MS (default 480ms), or longPress: false
Typeahead buffer TYPEAHEAD_MS (default 500ms)
Hover-grace wait AIM_MS (default 300ms)
Submenu overlap OVERLAP (default 6px)
Spring strength the back.out(2.6) ease and scale: 0.55 start in open
Row stagger stagger in the rows fromTo (default 0.035s)
Colours, radii, shadows the body[data-variant] blocks in assets/style.css

Requirements

  • GSAP 3.12+ (core only)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.