GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Context Menu UI: 9 Right-Click Menu Patterns

One menu position value is set from the clicked coordinate value, one flips its direction value near a screen edge, and one shifts sideways by a depth value as

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine ever touch a real filesystem or clipboard action — every "Copy," "Rename," or "Delete" button only swaps a CSS class, and 09's long-press ring is driven by a local requestAnimationFrame loop rather than any native touch API. All nine also answer a genuine user input rather than running on autoplay alone: eight open through a real contextmenu event with preventDefault() called first, and 09 opens instead through a timed long-press built from pointerdown/pointerup/pointerleave — eight plus one, nine real triggers out of nine.

Anatomy of nine patterns — which value changes and when

Nine patterns split by what a click coordinate is actually used for once a menu opens, and that split is what keeps a context menu ui design collection from reading as nine identical boxes popping up under nine right-click prompts.

Why three patterns spend the click coordinate only on where the box lands

01, 02, and 03 never touch a submenu, a keyboard index, or a dismiss timer — each one exists purely to decide where the menu box appears from a single clicked point. 01's positionAt() feeds the raw clientX/clientY straight into a translate(), clamped per axis so the box never crosses GAP from either edge. 02's flipFor() runs that same clamp but first checks whether the box's projected right or bottom edge would cross the viewport, and reverses the anchor on whichever axis overflows. 03's openAt() centers the box on the click point, then works backward from wherever the clamp nudged it to compute a transformOrigin percentage — the same click point drives both the position and the direction the scale animation grows from.

Why three patterns reshape the menu over time instead of over space

04, 07, and 08 don't touch a coordinate at all once the menu is already open — each reshapes the box against a duration or a depth instead. 04's scheduleSub() starts a setTimeout keyed to CASCADE_MS the moment a pointer enters the Sort row, and clearHover() cancels it the instant the pointer leaves, so only a hover that outlasts that timer ever triggers place() on the submenu. 07's applyStagger() multiplies each row's own index by STAGGER_MS and writes it straight into that row's transitionDelay, so CSS handles the actual reveal once the delay values are set. 08's goTo() multiplies a depth counter by a fixed PANEL_W and applies it as a single translateX() on the whole track, so every step in or out of a submenu moves the same distance regardless of which panel it is.

Why three patterns swap the mouse for a different real input entirely

05, 06, and 09 each replace part of the usual click-to-open, click-to-close cycle with a different input. 05's moveHighlight() never reads a pointer position — document.keydown drives (hi + dir + n) % n so ArrowUp and ArrowDown wrap the highlighted row around the ends of the list. 06's isInside() still reads a pointer coordinate, but only to decide whether a pointerdown on the full-screen backdrop landed outside the menu's own getBoundingClientRect(), fading both away together when it did. 09 replaces contextmenu as the trigger altogether — tickPress() recomputes a stroke-dashoffset from elapsed time on every animation frame while a finger is down, and only calls finishPress() once that progress value reaches 1.

Code per pattern

01Cursor position anchor — the clicked coordinate value directly becomes the menu's spawn position

The screen coordinate value where a right-click happens becomes the menu box's own position value with nothing in between, and once the menu closes that position value is discarded — the kind of anchor a file list row or an editor's text selection uses.

Cursor position anchor — the clicked coordinate value directly becomes the menu's spawn position
Open in a new tab

01 · Cursor position anchor — the clicked coordinate value directly becomes the menu's spawn position — Measured area 51.248% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

positionAt(x, y)GAP = 8menu.style.transform = translate(px,py)

What the numbers say

Across 23 tracked frames, 01 moves on 7 of them, covering a 51.25% cumulative area at a 118.5 average intensity — the highest cumulative area of the nine, because the autoplay loop reopens the whole menu box at four different points spread across the field, so each appearance repaints a different region of the 480×300 stage rather than the same corner every time.

  var GAP = 8; // margin kept at the screen edge

  function positionAt(x, y) {
    var mw = menu.offsetWidth, mh = menu.offsetHeight;
    var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
    // Calculation layer: use the click coordinate value as-is, but clamp each axis
    // independently so the menu box never leaves the screen.
    var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
    var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
    menu.style.transform = 'translate(' + px.toFixed(0) + 'px,' + py.toFixed(0) + 'px)';
  }

02Viewport edge flip — the opening direction value reverses once the position value reaches a screen edge

Once the menu box's projected right edge would cross the screen width value, the opening direction value flips to the left of the click point instead, and the same happens on the bottom edge with an upward direction value — useful for a right-click near the far edge of a wide table or a card grid.

Viewport edge flip — the opening direction value reverses once the position value reaches a screen edge
Open in a new tab

02 · Viewport edge flip — the opening direction value reverses once the position value reaches a screen edge — Measured area 45.69% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

flipFor(x, y)flip-x / flip-y(x + mw) > vw

What the numbers say

Across 23 tracked frames, 02 moves on 8 of them, covering a 45.69% cumulative area at a 112.7 average intensity — the second-highest area of the nine, since the autoplay loop alternates between a top-left field with no flip and a bottom-right field where both flip-x and flip-y apply, so the box's scale-and-fade animation repaints two opposite corners of the stage in turn.

  var GAP = 4;

  function flipFor(x, y) {
    var mw = menu.offsetWidth, mh = menu.offsetHeight;
    var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
    // Calculation layer: first check whether the menu box's right/bottom edge
    // (x+mw, y+mh) would cross the screen width/height value, then flip the
    // opening direction only on the axis that overflows, offsetting the anchor
    // by the menu's own size.
    var flipX = (x + mw) > vw;
    var flipY = (y + mh) > vh;
    var px = flipX ? (x - mw) : x;
    var py = flipY ? (y - mh) : y;
    px = Math.max(GAP, Math.min(px, vw - mw - GAP));
    py = Math.max(GAP, Math.min(py, vh - mh - GAP));
    menu.style.left = px.toFixed(0) + 'px';
    menu.style.top = py.toFixed(0) + 'px';
    menu.classList.toggle('flip-x', flipX);
    menu.classList.toggle('flip-y', flipY);
  }

03Scale fade-in — size and opacity values grow together from a smaller starting value

Once the menu opens, its size value grows from a smaller starting value to full size and its opacity value grows from 0 to 1 over the same duration, and closing runs those same values back down — a short action menu or a table row's right-click menu both read this way.

Scale fade-in — size and opacity values grow together from a smaller starting value
Open in a new tab

03 · Scale fade-in — size and opacity values grow together from a smaller starting value — Measured area 37.249% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

openAt(x, y)transform-origin: originX% originY%((x - left) / mw) * 100

What the numbers say

Across 23 tracked frames, 03 moves on 7 of them, covering a 37.25% cumulative area at a 57.2 average intensity — the lowest average intensity of the nine, because a scale animation changes pixels gradually across the whole box rather than in the sharper per-frame jumps a position change or a color flash produces.

  var GAP = 6;

  function openAt(x, y) {
    var mw = menu.offsetWidth, mh = menu.offsetHeight;
    var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
    // Calculation layer: place the menu so the click coordinate lands at its
    // center, then clamp to the screen edges.
    var left = Math.max(GAP, Math.min(x - mw / 2, vw - mw - GAP));
    var top = Math.max(GAP, Math.min(y - mh / 2, vh - mh - GAP));
    // Even after the clamp nudges the box, work back out what percentage point
    // inside that box the actual click landed at, and use it as the origin.
    var originX = Math.max(0, Math.min(100, ((x - left) / mw) * 100));
    var originY = Math.max(0, Math.min(100, ((y - top) / mh) * 100));
    menu.style.left = left.toFixed(0) + 'px';
    menu.style.top = top.toFixed(0) + 'px';
    menu.style.transformOrigin = originX.toFixed(1) + '% ' + originY.toFixed(1) + '%';
    menu.classList.add('is-open');
  }

04Submenu cascade open — a submenu opens once the hovered duration value passes a set amount

The duration value spent hovering the Sort row has to pass CASCADE_MS before its submenu appears beside it, and moving to a different row resets that hovered duration value back to zero — this is how a multi-level edit menu or a file-type submenu avoids opening for every cursor that merely passes through.

Submenu cascade open — a submenu opens once the hovered duration value passes a set amount
Open in a new tab

04 · Submenu cascade open — a submenu opens once the hovered duration value passes a set amount — Measured area 15.367% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

scheduleSub()CASCADE_MS = 260place(sub, mr.right - 2, tr.top - 4)

What the numbers say

Across 23 tracked frames, 04 moves on 6 of them, covering a 15.37% cumulative area at a 65.9 average intensity — the fewest moved frames of the nine, because most of the autoplay cycle is spent simply holding the pointer over the Sort row while CASCADE_MS counts down, with nothing on screen changing until the submenu actually appears.

  function clearHover() { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }

  function scheduleSub() {
    clearHover();
    // Calculation layer: measure how long the pointer has stayed over the Sort
    // row as CASCADE_MS, and only open the submenu once that time is passed
    // (not immediately — so a cursor just passing through doesn't trigger it).
    hoverTimer = setTimeout(function () {
      var mr = menu.getBoundingClientRect();
      var tr = trigger.getBoundingClientRect();
      place(sub, mr.right - 2, tr.top - 4);
      sub.classList.add('is-open');
    }, CASCADE_MS);
  }

05Keyboard arrow highlight — the highlighted position value moves through the list by each key press

Each up or down arrow key press moves the highlighted row's order value by one step, and once that order value passes either end of the list it wraps back around to the opposite end — built for a menu navigated without a mouse, or an accessible right-click menu on a data table.

Keyboard arrow highlight — the highlighted position value moves through the list by each key press
Open in a new tab

05 · Keyboard arrow highlight — the highlighted position value moves through the list by each key press — Measured area 19.524% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

moveHighlight(dir)(hi + dir + n) % nArrowUp / ArrowDown

What the numbers say

Across 23 tracked frames, 05 moves on 14 of them, covering a 19.52% cumulative area at a 110.1 average intensity — a high frame count relative to its area, because each of the six simulated key presses only recolors one row's highlight background rather than moving or resizing anything, so the area stays small while the frame count adds up one press at a time.

  // Calculation layer: derive the next index as (current index + direction +
  // count) % count, producing a wraparound move that jumps to the opposite end
  // once it passes the top or bottom of the list.
  function moveHighlight(dir) {
    if (!menu.classList.contains('is-open')) return;
    var n = items.length;
    setHi((hi + dir + n) % n);
  }

06Backdrop dismiss fade — the opacity value drops to zero once a click outside the menu is registered

Once a click lands outside the open menu, the opacity value of both the menu and a full-screen backdrop behind it drops to zero over a set duration and the menu leaves the screen — the ordinary way a right-click menu or a mobile long-press menu closes.

Backdrop dismiss fade — the opacity value drops to zero once a click outside the menu is registered
Open in a new tab

06 · Backdrop dismiss fade — the opacity value drops to zero once a click outside the menu is registered — Measured area 88.409% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

isInside(rect, x, y)backdrop pointerdownopacity 1 → 0 fade

What the numbers say

Across 23 tracked frames, 06 moves on 8 of them, covering an 88.41% cumulative area at a 46.0 average intensity — the highest cumulative area and the lowest average intensity of the nine, because a backdrop spanning nearly the whole 480×300 stage fades in and out alongside the menu, covering almost the entire frame but only by a shallow, gradual opacity change each time.

  function close() {
    menu.classList.remove('is-open');
    backdrop.classList.remove('is-on');
  }

  // Calculation layer: judge whether the click coordinate falls inside the
  // menu's bounding rect (getBoundingClientRect) with four inequalities, and
  // fade the backdrop out together with the menu only when it lands outside.
  function isInside(rect, x, y) {
    return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
  }

07Row stagger reveal — each row's position fills in after a delay proportional to its own order value

Once the menu opens, each row's position value and opacity value return to their full value after a delay proportional to that row's own order value, so a row lower in the list settles into place later — meant for an edit menu with many rows, or one mixing color and sort options.

Row stagger reveal — each row's position fills in after a delay proportional to its own order value
Open in a new tab

07 · Row stagger reveal — each row's position fills in after a delay proportional to its own order value — Measured area 19.279% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

applyStagger()STAGGER_MS = 55el.style.transitionDelay = i * STAGGER_MS

What the numbers say

Across 23 tracked frames, 07 moves on 8 of them, covering a 19.28% cumulative area at an 83.8 average intensity — a moderate intensity that comes from four rows each sliding in from translateX(-8px) to 0 in sequence rather than all four settling in the same frame, so the pixels that change shift row to row instead of piling into one moment.

  // Calculation layer: multiply each row's own order index (i) by STAGGER_MS
  // and write it inline as that row's own transition-delay, producing a
  // sequential reveal that fills in from the top with a gap between rows.
  function applyStagger() {
    items.forEach(function (el, i) { el.style.transitionDelay = (i * STAGGER_MS) + 'ms'; });
  }
  function clearStagger() {
    items.forEach(function (el) { el.style.transitionDelay = ''; });
  }

  function open(x, y) {
    positionAt(x, y);
    applyStagger();
    menu.classList.add('is-open');
  }
  function close() {
    menu.classList.remove('is-open');
    clearStagger();
  }

08Nested depth slide — the whole menu shifts sideways by a distance tied to the entered depth value

Each time the entered submenu depth value increases by one, the menu's horizontal position value shifts sideways by that submenu's own width value, and returning to the parent decreases the depth value while the position value reverses — this suits a tool with several submenu levels, or a layered menu on a mobile screen.

Nested depth slide — the whole menu shifts sideways by a distance tied to the entered depth value
Open in a new tab

08 · Nested depth slide — the whole menu shifts sideways by a distance tied to the entered depth value — Measured area 15.822% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

goTo(d)PANEL_W = 168translateX(-depth * PANEL_W)

What the numbers say

Across 23 tracked frames, 08 moves on 8 of them, covering a 15.82% cumulative area at a 94.8 average intensity — the smallest cumulative area of the nine, because only the track's own translateX() slide changes on any given frame, and the panel width (PANEL_W, 168px) it slides by is narrower than the full menu box every other pattern fades or repositions.

  // Calculation layer: multiply the depth value by the panel width (PANEL_W) to
  // shift the track sideways, so each time depth increases by 1 exactly one
  // panel's width of the next screen slides into view.
  function goTo(d) {
    depth = d;
    track.style.transform = 'translateX(' + (-depth * PANEL_W) + 'px)';
  }

  function open(x, y) { positionAt(x, y); goTo(0); menu.classList.add('is-open'); }
  function close() { menu.classList.remove('is-open'); goTo(0); }

09Long-press reveal — the menu appears once the pressed duration value passes a threshold

Once the duration value the screen has been pressed passes HOLD_MS, the menu appears anchored to the pressed coordinate value, and releasing before that threshold shows no menu at all — built for long-pressing a list row on mobile, or long-pressing an image on a touch screen.

Long-press reveal — the menu appears once the pressed duration value passes a threshold
Open in a new tab

09 · Long-press reveal — the menu appears once the pressed duration value passes a threshold — Measured area 19.026% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

tickPress(ts)HOLD_MS = 620 / CIRC = 100.5stroke-dashoffset = CIRC * (1 - prog)

What the numbers say

Across 23 tracked frames, 09 moves on 20 of them, covering a 19.03% cumulative area at a 112.9 average intensity — the highest moved-frame count of the nine, because tickPress() recomputes the progress ring's stroke-dashoffset on every requestAnimationFrame tick for the full length of the hold, so nearly every tracked frame shows some change even though the ring itself stays small.

  // Calculation layer: clamp (elapsed time / HOLD_MS) to a 0-1 progress value
  // and multiply it by the circle's circumference (CIRC) to recompute the
  // stroke-dashoffset value every frame — time itself becomes the amount the
  // ring fills.
  function tickPress(ts) {
    var elapsed = performance.now() - pressStart;
    var prog = Math.max(0, Math.min(1, elapsed / HOLD_MS));
    ringFg.setAttribute('stroke-dashoffset', (CIRC * (1 - prog)).toFixed(1));
    if (prog >= 1) { finishPress(); return; }
    rafId = requestAnimationFrame(tickPress);
  }
  function startPress(x, y) {
    pressX = x; pressY = y;
    pressStart = performance.now();
    ring.classList.add('is-active');
    ringFg.setAttribute('stroke-dashoffset', CIRC.toFixed(1));
    rafId = requestAnimationFrame(tickPress);
  }

Where values reshape a pattern — position, time, and depth boundaries

Why 04's CASCADE_MS decides how easily a submenu opens by accident, not how the submenu looks once open

CASCADE_MS only gates when place() runs — it changes nothing about the submenu's own scale-and-fade transition or its position math once that timer fires. At 260ms a cursor has to rest on the Sort row for a real moment before the submenu commits, which is forgiving of a cursor sliding past on its way somewhere else; at 120ms, a light brush across the row is enough to trigger it, so this custom context menu javascript pattern opens faster but also opens more often than intended.

Why 07's STAGGER_MS decides how spread out the reveal feels, not how many rows appear

STAGGER_MS is multiplied by a fixed row index — it never changes which four rows appear, only how far apart in time their transitionDelay values land. At 55ms the four rows arrive almost as one block; at 90ms each row's entrance is spaced out enough that a visitor can watch them settle in one after another rather than reading it as a single reveal.

Why 09's HOLD_MS decides how deliberate a press must be, not where the menu ends up

HOLD_MS only changes the denominator tickPress() divides elapsed time by — it has no effect on pressX/pressY, which are captured once at startPress() and never revisited. At 620ms a slightly-too-long tap during a scroll can open the menu by accident before a finger lifts; at 900ms a visitor must commit to holding still, which cuts accidental opens but asks even an intentional press to wait longer.

Where it breaks — rapid clicks, narrow screens, closing midway

What happens on a rapid re-right-click while the menu is already open

01, 02, 03, 06, and 07 have no guard against a second contextmenu while is-open is still set — a rapid re-click just calls positionAt()/flipFor()/openAt() again, snapping the already-visible box to the new coordinate without replaying its opacity or scale transition, since the is-open class never toggled off. 04 is the exception: its handler calls closeAll() before reopening, so a rapid re-click also collapses any submenu that happened to be open. 09 doesn't guard pointerdown against an already-open menu either, so a second press while one menu is showing starts a fresh hold-and-fill cycle on top of it.

What happens when the field sits near a narrow screen's edge

Every one of the nine clamps its position inside GAP of the viewport through Math.max/Math.min, so none of them can render off the visible stage even under the @media (max-width: 340px) layout each demo also ships. But only 02 actually reverses its opening direction near an edge — the other eight just get squeezed flush against whichever boundary the clamp hits, which on a narrow screen can leave the box anchored well away from the coordinate that opened it.

What happens if the menu is dismissed midway through its own animation

Closing 04 while scheduleSub()'s timer is still pending calls clearHover() on pointerleave, so a submenu that hasn't reached CASCADE_MS yet simply never opens rather than opening late. Closing 09 mid-hold calls cancelPress() from pointerup or pointerleave, which cancels the pending animation frame and resets stroke-dashoffset back to a full circle instantly rather than letting the ring finish filling. Closing 07 mid-reveal calls clearStagger(), wiping every row's transitionDelay back to empty so the next open starts the sequence over rather than resuming mid-stagger.

Where this set differs from 9 CSS Tooltip Bubbles — one line, copy-paste, 9 CSS Screen Transition Animation Effects, and Toast Notification UI: 9 Stacking Patterns is what triggers the nine and what a coordinate means to each set. The tooltip set opens on hover or focus and anchors to the trigger element's own rect rather than a clicked point, and never has to decide what happens when a visitor dismisses it by clicking elsewhere. The screen-transition set swaps an entire view for another on navigation, so its nine values describe a full-viewport handoff rather than a small floating box anchored to one coordinate. The toast set stacks several timed, self-dismissing notifications in a fixed corner regardless of where anything was clicked, where here all nine specialize in one clicked or pressed point deciding a single box's position, direction, or depth. All nine source files behind this set, unlocked with the password ndf4j3ah, drop straight into a real right-click or long-press menu once the demo's autoplay loop is removed.

Three variants and accessibility

Only three of these nine context menu ui patterns expose a constant worth turning into a shipped variant — not because the other six lack tunable numbers, but because CASCADE_MS, STAGGER_MS, and HOLD_MS are the three where a single value change most visibly reshapes how forgiving, spread out, or deliberate the interaction feels.

Variant What changes What it trades off
04's CASCADE_MS from 260ms to 120ms How long a cursor must rest on the Sort row before its submenu commits At 260ms a cursor merely passing over the row never opens the submenu, so accidental opens are rare; at 120ms even a light brush opens it right away, reading as faster but firing unintentionally more often
07's STAGGER_MS from 55ms to 90ms How far apart in time each row's reveal lands At 55ms the four rows arrive almost as one block; at 90ms each row settles in clearly after the last, making the row count easier to read but taking longer for the whole menu to finish filling in
09's HOLD_MS from 620ms to 900ms How long a press must be held before the menu appears At 620ms a slightly too-long tap during a scroll can open the menu before a finger lifts; at 900ms a visitor must commit to holding still, cutting accidental opens at the cost of a longer intentional wait

All nine use role="menu" and role="menuitem" on the menu box and its buttons, but none of the nine sets an aria-label or wires up an aria-live region anywhere — a screen reader gets no automatic announcement when a menu opens, a submenu cascades, or the keyboard highlight in 05 moves. Each item's own @media (prefers-reduced-motion: reduce) query also turns off its .cm__menu's CSS transition entirely (07 additionally zeroes every row's transition-delay), so a real open or close under that setting is instant with no fade, scale, or slide — separately, each demo's own JS reduce check only gates the autoplay preview's setInterval loop, so the real contextmenu, pointerdown, keydown, and long-press listeners keep working exactly the same either way. Only 05 offers arrow-key navigation among the nine.

FAQ

Does the browser's own right-click menu still show up underneath these nine?

No — every one of the nine calls e.preventDefault() inside its contextmenu handler before it ever calls open(), which is what stops the browser's native menu from appearing in its place. 09 calls that same preventDefault() on contextmenu too, even though its own menu opens through a long-press rather than through that event.

Do any of these nine actually copy, share, or delete anything for real?

No — every "Copy," "Paste," "Rename," "Delete," "Share," and "Sort" button only toggles a CSS class or, for 04's submenu, calls place(); none of the nine calls the Clipboard API, fetch, or any other real action. A css custom right click menu built this way is meant to be wired to a real handler after pasting it in, not before.

Is a mobile long-press menu built with the same code as a desktop right-click menu?

Not quite — 09 replaces the contextmenu event the other eight rely on with a timed pointerdown/pointerup cycle through tickPress(), since a touch screen has no separate right-click gesture to listen for. Once open, though, 09's own menu box still shares the same positionAt()-style clamping the other eight use, so only the trigger differs, not how the box lands on screen.

Sources: MDN — HTMLElement: contextmenu event · MDN — Element.getBoundingClientRect() · MDN — transform-function: translate() · MDN — prefers-reduced-motion

Enter the archive password

The password is inside this article. You will find it as you read.