Skip to content
Star on GitHub
CollaborationContext menu
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.

Book a call

Context menu

Right-click an agent reply for its actions, with a submenu that asks the agent about it. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add context-menu
Agent2:14 pm

Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add context-menu

React

$npx designme add context-menu --react

Astro

$npx designme add context-menu --astro

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.

shadcn CLI, React

$npx shadcn@latest add https://designme.dev/r/shadcn/context-menu.json

shadcn CLI, Astro

$npx shadcn@latest add https://designme.dev/r/shadcn/astro/context-menu.json

In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.

02

About

What it is for and what it does.

A menu that opens where you right-click an agent reply, or where you long-press it on a touch screen, or at its corner with Shift+F10 or the Menu key. It holds the usual actions with their shortcuts, an Ask the agent item with a submenu (Explain, Summarise, Rewrite shorter), a checkable item and a delete that asks again before it runs. In the demo the agent's summary streams in under the reply.

  • On messages, files or rows that have several actions you don't want to show as buttons all the time.
  • When some of those actions hand the item to the agent, grouped in a submenu.
  • When a destructive action needs a second press rather than a separate dialog.
03

Usage

Import it in React and drive it from your own state, or place it in an Astro page.

React

import { ContextMenu } from '@/components/designme/context-menu/ContextMenu';

React example

The demo above as a React component, 69 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: right-click the reply, ask the agent for a
// summary, and it streams in under the reply.
import { useRef, useState } from 'react';
import { ContextMenu, type ContextMenuItem } from './ContextMenu';
import { Icon } from '../lib/icons';
import { useStory, useWait } from '../lib/useScript';

const TEXT = 'Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.';
const ASK: Record<string, [string, 'info' | 'list' | 'edit', string]> = {
  explain: ['Explanation', 'info', 'On a phone the keyboard takes the lower half of the screen. The Continue button sits there, so people have to close the keyboard to find it, and many leave instead.'],
  sum: ['Summary', 'list', 'Mobile shoppers drop at the address step because the keyboard hides Continue. Moving it up could win back about 300 orders a week.'],
  rewrite: ['Shorter', 'edit', 'Mobile checkout loses people at the address step: the keyboard hides Continue. Move it up to win back about 300 orders a week.']
};

export default function ContextMenuDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [follow, setFollow] = useState(false);
  const [pinned, setPinned] = useState(false);
  const [deleted, setDeleted] = useState(false);
  const [note, setNote] = useState('');
  const [out, setOut] = useState<{ k: string; text: string; busy: boolean } | null>(null);
  const run = useRef(0);
  const items: ContextMenuItem[] = [
    { key: 'copy', label: 'Copy text', icon: 'copy', shortcut: '⌘C' },
    { key: 'quote', label: 'Quote in reply', icon: 'quote', shortcut: 'R' },
    { key: 'pin', label: pinned ? 'Unpin' : 'Pin to thread', icon: 'pin', shortcut: 'P' },
    { key: 'ask', label: 'Ask the agent', icon: 'spark', separator: true, items: [
      { key: 'explain', label: 'Explain', icon: 'info' }, { key: 'sum', label: 'Summarise', icon: 'list' }, { key: 'rewrite', label: 'Rewrite shorter', icon: 'edit' }] },
    { key: 'follow', label: 'Follow replies', icon: 'bell', checked: follow },
    { key: 'del', label: 'Delete reply', icon: 'trash', shortcut: '⌫', danger: true, confirmLabel: 'Press again to delete', separator: true }
  ];
  const ask = async (k: string) => {
    const id = ++run.current, text = ASK[k][2].split(' ');
    setOut({ k, text: '', busy: true }); await wait(900);
    for (let i = 1; i <= text.length && id === run.current; i++) { setOut({ k, text: text.slice(0, i).join(' '), busy: false }); await wait(55); }
  };
  const select = (k: string) => {
    if (k === 'copy') { navigator.clipboard?.writeText(TEXT).catch(() => {}); setNote('Copied to clipboard'); }
    if (k === 'quote') setNote('Quoted in your reply');
    if (k === 'pin') { setPinned(p => !p); setNote(pinned ? 'Unpinned' : 'Pinned to the thread'); }
    if (k === 'follow') { setFollow(f => !f); setNote(follow ? 'You will not hear about replies' : 'You will hear about replies to this'); }
    if (k === 'del') { setDeleted(true); setNote(''); }
    if (ASK[k]) void ask(k);
  };
  // the preview's story: right-click the reply, open Ask the agent, pick Summarise
  useStory(ref, async ({ wait, press }) => {
    await wait(1500);
    const p = ref.current?.querySelector('.dm-cxm-tx'); if (!p) return;
    const b = p.getBoundingClientRect();
    p.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: b.left + b.width * .62, clientY: b.top + b.height * .58 }));
    await wait(1000); press('.dm-cxm-sub');
    await wait(1000); press('.dm-cxm-pop2 [data-k="sum"]');
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      {deleted
        ? <div className="dm-cxm" data-dm=""><div className="dm-cxm-gone">Reply deleted<button type="button" className="dm-pill dm-ghost dm-sm" onClick={() => setDeleted(false)}>Undo</button></div></div>
        : <ContextMenu items={items} onSelect={select} label="Reply actions" targetLabel="Agent reply. Press Shift+F10 for actions">
            <div className="dm-cxm-hd"><span className="dm-cxm-av"><Icon name="spark" /></span><b>Agent</b><span className="dm-muted dm-num">2:14 pm</span>{pinned && <span className="dm-cxm-pin"><Icon name="pin" />Pinned</span>}</div>
            <p className="dm-cxm-tx">{TEXT}</p>
            <div className="dm-cxm-out" aria-live="polite">{out && <div className="dm-cxm-res dm-enter"><span className="dm-k"><Icon name={ASK[out.k][1]} size="xs" /><span className={out.busy ? 'dm-shim' : undefined}>{out.busy ? `Writing a ${ASK[out.k][0].toLowerCase()}` : ASK[out.k][0]}</span></span><span>{out.text}</span></div>}</div>
          </ContextMenu>}
      <p className="dm-cxm-note dm-muted" data-dm="" aria-live="polite">{note}</p>
    </div>
  );
}

Astro

In a page in src/pages. It plays the demo above; change its context-menu.js to show your own data. Attributes like class go on its root element.

---
import ContextMenu from '../components/designme/context-menu/ContextMenu.astro';
---
<ContextMenu />
<ContextMenu data-style="soft" class="my-class" />
04

Props

Everything ContextMenu takes.

PropTypeWhat it does
itemsrequiredContextMenuItem[]
onSelectrequired(key: string, item: ContextMenuItem) => voidCalled with the item chosen (in a submenu too)
labelstringThe menu's name for screen readers, like "Reply actions"
targetLabelstringThe target's name for screen readers. Defaults to "Press Shift+F10 for actions"
targetClassNamestringClasses for the target. Defaults to an agent reply's panel
childrenrequiredReactNodeWhat you right-click: an agent reply, a row, a file
classNamestring
05

Code

The full source, as the CLI installs it.

ContextMenu.tsxReact · 138 lines
'use client';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useIsoLayoutEffect } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import '../designme.css';
import './context-menu.css';

export interface ContextMenuItem {
  key: string;
  label: string;
  icon?: IconName;
  /** A hint at the right, like "⌘C" */
  shortcut?: string;
  /** Makes it a checkable item (menuitemcheckbox) */
  checked?: boolean;
  /** Red, for something that deletes */
  danger?: boolean;
  /** Asks again before it runs: the first press changes the item to this label, the second runs it */
  confirmLabel?: string;
  /** A submenu, opened with the pointer resting on the item, Right or Enter */
  items?: ContextMenuItem[];
  /** A hairline above this item */
  separator?: boolean;
}

export interface ContextMenuProps {
  items: ContextMenuItem[];
  /** Called with the item chosen (in a submenu too) */
  onSelect: (key: string, item: ContextMenuItem) => void;
  /** The menu's name for screen readers, like "Reply actions" */
  label?: string;
  /** The target's name for screen readers. Defaults to "Press Shift+F10 for actions" */
  targetLabel?: string;
  /** Classes for the target. Defaults to an agent reply's panel */
  targetClassName?: string;
  /** What you right-click: an agent reply, a row, a file */
  children: ReactNode;
  className?: string;
}

type At = { x: number; y: number; keyed: boolean };

/** A menu that opens where you right-click, long-press, or press Shift+F10 or the Menu key, with a submenu, a checkable item and a delete that asks again. */
export function ContextMenu({ items, onSelect, label = 'Actions', targetLabel = 'Press Shift+F10 for actions', targetClassName = 'dm-cxm-msg dm-panel', children, className }: ContextMenuProps) {
  const root = useRef<HTMLDivElement>(null), target = useRef<HTMLDivElement>(null), menu = useRef<HTMLDivElement>(null), subMenu = useRef<HTMLDivElement>(null);
  const [at, setAt] = useState<At | null>(null);
  const [sub, setSub] = useState<{ key: string; focus: boolean } | null>(null);
  const [asking, setAsking] = useState<string | null>(null);
  const openedAt = useRef(0), press = useRef<ReturnType<typeof setTimeout>>(undefined), hov = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => { clearTimeout(press.current); clearTimeout(hov.current); }, []);
  const open = !!at;
  const itemsOf = (m: HTMLElement | null) => m ? [...m.querySelectorAll<HTMLElement>('.dm-mi')] : [];
  const bounds = () => { const card = (root.current!.closest('[data-c],.dm-card') || document.body).getBoundingClientRect(); return { lo: Math.max(card.left, 0) + 8, hi: Math.min(card.right, innerWidth) - 8 }; };
  // where a menu goes: at the point, turned back from the right and bottom edges so it stays on screen
  const fit = (m: HTMLElement, x: number, y: number) => {
    const rb = root.current!.getBoundingClientRect(), { lo, hi } = bounds(), w = m.offsetWidth, h = m.offsetHeight;
    if (rb.left + x + w > hi) x -= w;
    x = Math.max(lo - rb.left, Math.min(x, hi - rb.left - w));
    // near the foot of the screen it opens upward, but only where it fits over the target, so it never covers what is above
    const up = rb.top + y + h > innerHeight - 8 && y - h >= 0 && rb.top + y - h > 8; if (up) y -= h;
    m.style.left = x + 'px'; m.style.top = y + 'px'; m.style.setProperty('--dm-origin', (up ? 'bottom ' : 'top ') + 'left');
  };
  useIsoLayoutEffect(() => { if (at && menu.current) { fit(menu.current, at.x, at.y); if (at.keyed) itemsOf(menu.current)[0]?.focus({ preventScroll: true }); } }, [at]);
  // the submenu opens beside its item, on the right or else the left; where neither side has room it drops under the item
  useIsoLayoutEffect(() => {
    const m = subMenu.current, p = menu.current; if (!sub || !m || !p) return;
    const row = p.querySelector<HTMLElement>(`[data-k="${sub.key}"]`)!, pr = p.getBoundingClientRect(), b = row.getBoundingClientRect(), rb = root.current!.getBoundingClientRect(), { lo, hi } = bounds(), w = m.offsetWidth;
    if (pr.right - 4 + w <= hi) fit(m, pr.right - rb.left - 4, b.top - rb.top - 6);
    else if (pr.left + 4 - w >= lo) fit(m, pr.left - rb.left + 4 - w, b.top - rb.top - 6);
    else fit(m, b.left - rb.left + 24, b.bottom - rb.top + 2);
    if (sub.focus) itemsOf(m)[0]?.focus({ preventScroll: true });
  }, [sub]);
  const openAt = (x: number, y: number, keyed: boolean) => { setSub(null); setAsking(null); openedAt.current = performance.now(); setAt({ x, y, keyed }); };
  const close = (back = false) => {
    const inside = !!(menu.current?.contains(document.activeElement) || subMenu.current?.contains(document.activeElement));
    setAt(null); setSub(null); setAsking(null); if (back && inside) target.current?.focus({ preventScroll: true });
  };
  // a press anywhere else closes it
  useEffect(() => {
    if (!open) return;
    const down = (e: PointerEvent) => { const t = e.target as Node; if (!menu.current?.contains(t) && !subMenu.current?.contains(t)) close(); };
    document.addEventListener('pointerdown', down, true);
    return () => document.removeEventListener('pointerdown', down, true);
  }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
  const local = (cx0: number, cy0: number) => { const rb = root.current!.getBoundingClientRect(); return [cx0 - rb.left, cy0 - rb.top] as const; };
  // a submenu opened with Enter or Space takes the focus; one opened with a click (or by a demo) leaves it
  const choose = (it: ContextMenuItem, inSub: boolean, keyed: boolean) => {
    if (it.items) { setSub(s => s ? null : { key: it.key, focus: keyed }); return; }
    if (it.confirmLabel && asking !== it.key) { setAsking(it.key); return; }
    if (it.checked === undefined || inSub) close(true);
    onSelect(it.key, it);
  };
  const keys = (list: ContextMenuItem[], inSub: boolean) => (e: React.KeyboardEvent<HTMLDivElement>) => {
    const its = itemsOf(e.currentTarget), i = its.indexOf(document.activeElement as HTMLElement), k = e.key;
    let to: HTMLElement | undefined;
    if (k === 'ArrowDown') to = its[(i + 1) % its.length]; else if (k === 'ArrowUp') to = its[(i - 1 + its.length) % its.length];
    else if (k === 'Home') to = its[0]; else if (k === 'End') to = its[its.length - 1];
    else if (k === 'ArrowRight' && !inSub && list[i]?.items) { e.preventDefault(); setSub({ key: list[i].key, focus: true }); return; }
    else if ((k === 'ArrowLeft' || k === 'Escape') && inSub) { e.preventDefault(); e.stopPropagation(); const key = sub?.key; setSub(null); menu.current?.querySelector<HTMLElement>(`[data-k="${key}"]`)?.focus({ preventScroll: true }); return; }
    else if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
    else if (k === 'Tab') { close(); return; }
    else if (k.length === 1 && /\S/.test(k)) { const n = its.length, from = Math.max(i, 0); for (let j = 1; j <= n; j++) { const it = its[(from + j) % n]; if ((it.textContent || '').trim().toLowerCase().startsWith(k.toLowerCase())) { to = it; break; } } }
    if (to) { e.preventDefault(); to.focus({ preventScroll: true }); if (!inSub && !list[its.indexOf(to)]?.items) setSub(null); }
  };
  const row = (it: ContextMenuItem, inSub: boolean) => {
    const ask = asking === it.key;
    return (
      <Fragment key={it.key}>
        {it.separator && <div className="dm-sep" role="separator" />}
        <button type="button" data-k={it.key} role={it.checked !== undefined ? 'menuitemcheckbox' : 'menuitem'} aria-checked={it.checked} aria-haspopup={it.items ? 'menu' : undefined}
          aria-expanded={it.items ? sub?.key === it.key : undefined} aria-label={ask ? `${it.label}: press again to confirm` : undefined}
          className={cx('dm-mi', it.items && 'dm-cxm-sub', it.danger && 'dm-cxm-del', ask && 'dm-ask')} onClick={e => choose(it, inSub, e.isTrusted && e.detail === 0)}
          onPointerEnter={() => { if (inSub) return; clearTimeout(hov.current); hov.current = setTimeout(() => setSub(it.items ? { key: it.key, focus: false } : null), it.items ? 160 : 200); }}>
          {it.icon && <Icon name={it.icon} />}{ask ? it.confirmLabel : it.label}
          {it.items ? <span className="dm-r"><Icon name="chev-r" size="xs" /></span> : it.checked !== undefined ? <span className="dm-ck"><Icon name="check" size="xs" /></span> : it.shortcut && <span className="dm-r">{it.shortcut}</span>}
        </button>
      </Fragment>
    );
  };
  const subItems = sub ? items.find(i => i.key === sub.key)?.items : undefined;
  return (
    <div ref={root} data-dm="" className={cx('dm-cxm', className)}>
      <div ref={target} className={cx(targetClassName, open && 'dm-on')} tabIndex={0} aria-haspopup="menu" aria-label={targetLabel}
        onContextMenu={e => { e.preventDefault(); if (open && performance.now() - openedAt.current < 600) return; const [x, y] = local(e.clientX, e.clientY); openAt(x, y, e.isTrusted); }}
        onPointerDown={e => { if (e.pointerType !== 'touch') return; const [x, y] = local(e.clientX, e.clientY); clearTimeout(press.current); press.current = setTimeout(() => openAt(x, y, true), 500); }}
        onPointerUp={() => clearTimeout(press.current)} onPointerCancel={() => clearTimeout(press.current)}
        onKeyDown={e => { if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') { e.preventDefault(); const b = e.currentTarget.getBoundingClientRect(), [x, y] = local(b.left + 16, b.top + 36); openAt(x, y, true); } }}>
        {children}
      </div>
      <div ref={menu} className={cx('dm-pop dm-cxm-pop', open && 'dm-open')} role="menu" aria-label={label} inert={!open} onKeyDown={keys(items, false)}>{items.map(it => row(it, false))}</div>
      <div ref={subMenu} className={cx('dm-pop dm-cxm-pop2', open && subItems && 'dm-open')} role="menu" aria-label={items.find(i => i.key === sub?.key)?.label} inert={!(open && subItems)} onKeyDown={keys(subItems || [], true)}>
        {(subItems || []).map(it => row(it, true))}
      </div>
    </div>
  );
}
context-menu.cssReact · 29 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Context menu ---------- */
/* an agent reply with a menu of its own: right-click, long-press, Shift+F10 or the Menu key opens it where you are */
.dm-cxm{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-cxm-msg{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-2);font-size:13.5px;line-height:1.55;-webkit-touch-callout:none;transition:box-shadow var(--dm-t-fast)}
.dm-cxm-msg:focus-visible{outline-offset:3px;border-radius:var(--dm-r-card)}
.dm-cxm-msg.dm-on{box-shadow:var(--dm-shadow),0 0 0 2px color-mix(in srgb,var(--dm-line) 70%,transparent)}
.dm-cxm-hd{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.dm-cxm-hd b{font-weight:500}
.dm-cxm-hd .dm-muted{font-size:12px}
.dm-cxm-av{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-cxm-pin{display:inline-flex;align-items:center;gap:4px;margin-left:auto;font-size:12px;color:var(--dm-muted)}
.dm-cxm-tx{margin:0;color:var(--dm-fg)}
.dm-cxm-tx.dm-out{opacity:.4}
.dm-cxm-out:empty{display:none}
.dm-cxm-res{display:flex;flex-direction:column;gap:var(--dm-s-1);margin-top:var(--dm-s-1);padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2)}
.dm-cxm-res .dm-k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted)}
.dm-cxm-res .dm-k .dm-i{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-cxm-res .dm-w{animation:dm-tkword .42s var(--dm-ease) backwards}
.dm-cxm-note{margin:0;min-height:20px;font-size:12.5px;text-align:center}
.dm-cxm-gone{display:flex;align-items:center;justify-content:center;gap:var(--dm-s-2);padding:var(--dm-s-3);font-size:13px;color:var(--dm-muted)}
.dm-cxm-pop,.dm-cxm-pop2{top:0;left:0;min-width:220px}
.dm-cxm-pop2{min-width:180px}
.dm-cxm-pop .dm-mi .dm-r .dm-i{color:var(--dm-muted)}
.dm-cxm-sub[aria-expanded="true"]{background:var(--dm-row)}
.dm-cxm-del,.dm-cxm-del .dm-i{color:var(--dm-bad)}
.dm-cxm-del.dm-ask{background:var(--dm-bad-bg)}
:root:not([data-style="soft"]) .dm-cxm-msg.dm-on:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-fg2) 50%,transparent)}
ContextMenu.astroAstro · 46 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Context menu: Right-click an agent reply for its actions, with a submenu that asks the agent about it.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change context-menu.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './context-menu.css';

export interface Props extends HTMLAttributes<'div'> {
  /** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
  'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="context-menu" {...rest}>
  <div class="stage-in"><Fragment is:raw>
<div class="cxm">
  <div class="cxm-msg panel" tabindex="0" aria-haspopup="menu" aria-label="Agent reply. Press Shift+F10 for actions">
    <div class="cxm-hd"><span class="cxm-av"><svg class="i s"><use href="#i-spark"/></svg></span><b>Agent</b><span class="muted num">2:14 pm</span><span class="cxm-pin" hidden><svg class="i s"><use href="#i-pin"/></svg>Pinned</span></div>
    <p class="cxm-tx">Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.</p>
    <div class="cxm-out" aria-live="polite"></div>
  </div>
  <p class="cxm-note muted" aria-live="polite"></p>
  <div class="pop cxm-pop" role="menu" aria-label="Reply actions">
    <button class="mi" role="menuitem" data-a="copy"><svg class="i s"><use href="#i-copy"/></svg>Copy text<span class="r">⌘C</span></button>
    <button class="mi" role="menuitem" data-a="quote"><svg class="i s"><use href="#i-quote"/></svg>Quote in reply<span class="r">R</span></button>
    <button class="mi" role="menuitem" data-a="pin"><svg class="i s"><use href="#i-pin"/></svg><span class="t">Pin to thread</span><span class="r">P</span></button>
    <div class="sep" role="separator"></div>
    <button class="mi cxm-sub" role="menuitem" aria-haspopup="menu" aria-expanded="false" data-a="ask"><svg class="i s"><use href="#i-spark"/></svg>Ask the agent<span class="r"><svg class="i xs"><use href="#i-chev-r"/></svg></span></button>
    <button class="mi" role="menuitemcheckbox" aria-checked="false" data-a="follow"><svg class="i s"><use href="#i-bell"/></svg>Follow replies<span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
    <div class="sep" role="separator"></div>
    <button class="mi cxm-del" role="menuitem" data-a="del"><svg class="i s"><use href="#i-trash"/></svg><span class="t">Delete reply</span><span class="r">⌫</span></button>
  </div>
  <div class="pop cxm-pop2" role="menu" aria-label="Ask the agent">
    <button class="mi" role="menuitem" data-a="explain"><svg class="i s"><use href="#i-info"/></svg>Explain</button>
    <button class="mi" role="menuitem" data-a="sum"><svg class="i s"><use href="#i-list"/></svg>Summarise</button>
    <button class="mi" role="menuitem" data-a="rewrite"><svg class="i s"><use href="#i-edit"/></svg>Rewrite shorter</button>
  </div>
</div>
  </Fragment></div>
</div>
<script>
  import './context-menu.js';
</script>
context-menu.htmlHTML · 28 lines
<div class="card" data-c="context-menu">
  <div class="stage-in">
    <div class="cxm">
      <div class="cxm-msg panel" tabindex="0" aria-haspopup="menu" aria-label="Agent reply. Press Shift+F10 for actions">
        <div class="cxm-hd"><span class="cxm-av"><svg class="i s"><use href="#i-spark"/></svg></span><b>Agent</b><span class="muted num">2:14 pm</span><span class="cxm-pin" hidden><svg class="i s"><use href="#i-pin"/></svg>Pinned</span></div>
        <p class="cxm-tx">Most of the checkout drop-off is on mobile. 61% of the people who leave do it at the address step, where the keyboard covers the Continue button. Moving Continue above the keyboard should win back about 300 orders a week.</p>
        <div class="cxm-out" aria-live="polite"></div>
      </div>
      <p class="cxm-note muted" aria-live="polite"></p>
      <div class="pop cxm-pop" role="menu" aria-label="Reply actions">
        <button class="mi" role="menuitem" data-a="copy"><svg class="i s"><use href="#i-copy"/></svg>Copy text<span class="r">⌘C</span></button>
        <button class="mi" role="menuitem" data-a="quote"><svg class="i s"><use href="#i-quote"/></svg>Quote in reply<span class="r">R</span></button>
        <button class="mi" role="menuitem" data-a="pin"><svg class="i s"><use href="#i-pin"/></svg><span class="t">Pin to thread</span><span class="r">P</span></button>
        <div class="sep" role="separator"></div>
        <button class="mi cxm-sub" role="menuitem" aria-haspopup="menu" aria-expanded="false" data-a="ask"><svg class="i s"><use href="#i-spark"/></svg>Ask the agent<span class="r"><svg class="i xs"><use href="#i-chev-r"/></svg></span></button>
        <button class="mi" role="menuitemcheckbox" aria-checked="false" data-a="follow"><svg class="i s"><use href="#i-bell"/></svg>Follow replies<span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <div class="sep" role="separator"></div>
        <button class="mi cxm-del" role="menuitem" data-a="del"><svg class="i s"><use href="#i-trash"/></svg><span class="t">Delete reply</span><span class="r">⌫</span></button>
      </div>
      <div class="pop cxm-pop2" role="menu" aria-label="Ask the agent">
        <button class="mi" role="menuitem" data-a="explain"><svg class="i s"><use href="#i-info"/></svg>Explain</button>
        <button class="mi" role="menuitem" data-a="sum"><svg class="i s"><use href="#i-list"/></svg>Summarise</button>
        <button class="mi" role="menuitem" data-a="rewrite"><svg class="i s"><use href="#i-edit"/></svg>Rewrite shorter</button>
      </div>
    </div>
  </div>
</div>
context-menu.cssHTML · 28 lines
/* ---------- Context menu ---------- */
/* an agent reply with a menu of its own: right-click, long-press, Shift+F10 or the Menu key opens it where you are */
.cxm{position:relative;width:100%;display:flex;flex-direction:column;gap:var(--dm-s-2)}
.cxm-msg{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-2);font-size:13.5px;line-height:1.55;-webkit-touch-callout:none;transition:box-shadow var(--dm-t-fast)}
.cxm-msg:focus-visible{outline-offset:3px;border-radius:var(--dm-r-card)}
.cxm-msg.on{box-shadow:var(--dm-shadow),0 0 0 2px color-mix(in srgb,var(--dm-line) 70%,transparent)}
.cxm-hd{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.cxm-hd b{font-weight:500}
.cxm-hd .muted{font-size:12px}
.cxm-av{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.cxm-pin{display:inline-flex;align-items:center;gap:4px;margin-left:auto;font-size:12px;color:var(--dm-muted)}
.cxm-tx{margin:0;color:var(--dm-fg)}
.cxm-tx.out{opacity:.4}
.cxm-out:empty{display:none}
.cxm-res{display:flex;flex-direction:column;gap:var(--dm-s-1);margin-top:var(--dm-s-1);padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-row);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px;color:var(--dm-fg2)}
.cxm-res .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted)}
.cxm-res .k .i{color:var(--dm-accent-fg,var(--dm-accent))}
.cxm-res .w{animation:tkword .42s var(--dm-ease) backwards}
.cxm-note{margin:0;min-height:20px;font-size:12.5px;text-align:center}
.cxm-gone{display:flex;align-items:center;justify-content:center;gap:var(--dm-s-2);padding:var(--dm-s-3);font-size:13px;color:var(--dm-muted)}
.cxm-pop,.cxm-pop2{top:0;left:0;min-width:220px}
.cxm-pop2{min-width:180px}
.cxm-pop .mi .r .i{color:var(--dm-muted)}
.cxm-sub[aria-expanded="true"]{background:var(--dm-row)}
.cxm-del,.cxm-del .i{color:var(--dm-bad)}
.cxm-del.ask{background:var(--dm-bad-bg)}
:root:not([data-style="soft"]) .cxm-msg.on:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-fg2) 50%,transparent)}
context-menu.jsHTML · 114 lines
DM.def('context-menu', (r, c) => {
  const ICON = { spark:'spark', copy:'copy', quote:'quote', pin:'pin', 'chev-r':'chev-r', bell:'bell', check:'check', trash:'trash', info:'info', list:'list', edit:'edit' };
  r.innerHTML = r.innerHTML.replace(/\{([\w-]+)\}/g, (m, k) => ICON[k] ? I(ICON[k], k === 'chev-r' || k === 'check' ? 'xs' : 's') : m);
  const root = $('.cxm', r), msg = $('.cxm-msg', r), tx = $('.cxm-tx', r), out = $('.cxm-out', r), note = $('.cxm-note', r);
  const pop = $('.cxm-pop', r), sub = $('.cxm-pop2', r), subRow = $('.cxm-sub', r), del = $('.cxm-del', r);
  const items = m => $$('.mi', m);
  let open = false, subOpen = false, opener = null, openedAt = 0;
  // where a menu goes: at the point, turned back from the right and bottom edges so it stays on screen and inside the card
  const fit = (m, x, y, { left } = {}) => {
    const rb = root.getBoundingClientRect(), card = (root.closest('.card,.comp,[data-c]') || document.body).getBoundingClientRect();
    const lo = Math.max(card.left, 0) + 8 - rb.left, hi = Math.min(card.right, innerWidth) - 8 - rb.left, w = m.offsetWidth, h = m.offsetHeight;
    if (x + w > hi && left != null) x = left - w; else if (x + w > hi) x = x - w;
    x = Math.max(lo, Math.min(x, hi - w));
    // near the foot of the screen it opens upward, but only where it fits over the component, so it never covers what is above
    const up = rb.top + y + h > innerHeight - 8 && y - h >= 0 && rb.top + y - h > 8; if (up) y -= h;
    m.style.left = x + 'px'; m.style.top = y + 'px'; m.style.setProperty('--dm-origin', (up ? 'bottom ' : 'top ') + 'left');
  };
  const show = (m, on) => { m.classList.toggle('open', on); m.inert = !on; };
  function openAt(x, y, keyed){
    closeSub(); del.classList.remove('ask'); $('.t', del).textContent = 'Delete reply';
    open = true; openedAt = performance.now(); opener = document.activeElement; msg.classList.add('on'); show(pop, true); fit(pop, x, y);
    // opened by a person, the menu takes the focus on its first item; the demo leaves the focus where it is
    if (keyed) items(pop)[0].focus({ preventScroll:true });
  }
  function close(back){ if (!open) return; back = back && (pop.contains(document.activeElement) || sub.contains(document.activeElement)); open = false; closeSub(); show(pop, false); msg.classList.remove('on'); if (back) msg.focus({ preventScroll:true }); }
  // the submenu opens beside its row, on the right or else the left; where neither side has room (a phone) it drops under the row
  function openSub(focus){
    subOpen = true; subRow.setAttribute('aria-expanded', 'true'); show(sub, true);
    const p = pop.getBoundingClientRect(), b = subRow.getBoundingClientRect(), rb = root.getBoundingClientRect(), card = (root.closest('.card,.comp,[data-c]') || document.body).getBoundingClientRect(), w = sub.offsetWidth;
    const lo = Math.max(card.left, 0) + 8, hi = Math.min(card.right, innerWidth) - 8;
    if (p.right - 4 + w <= hi) fit(sub, p.right - rb.left - 4, b.top - rb.top - 6);
    else if (p.left + 4 - w >= lo) fit(sub, p.left - rb.left + 4 - w, b.top - rb.top - 6);
    else fit(sub, b.left - rb.left + 24, b.bottom - rb.top + 2);
    if (focus) items(sub)[0].focus({ preventScroll:true });
  }
  function closeSub(focus){ if (!subOpen) return; subOpen = false; subRow.setAttribute('aria-expanded', 'false'); show(sub, false); if (focus) subRow.focus({ preventScroll:true }); }
  show(pop, false); show(sub, false);
  // right-click, or a long press on a touch screen
  c.on(msg, 'contextmenu', e => { e.preventDefault(); if (open && performance.now() - openedAt < 600) return; const rb = root.getBoundingClientRect(); openAt(e.clientX - rb.left, e.clientY - rb.top, e.isTrusted && !e.button && e.detail === 0 ? false : e.isTrusted); });
  let press = 0;
  c.on(msg, 'pointerdown', e => { if (e.pointerType !== 'touch') return; const rb = root.getBoundingClientRect(), x = e.clientX - rb.left, y = e.clientY - rb.top; clearTimeout(press); press = setTimeout(() => openAt(x, y, true), 500); });
  ['pointerup', 'pointercancel', 'pointermove'].forEach(t => c.on(msg, t, e => { if (t !== 'pointermove' || Math.abs(e.movementX) + Math.abs(e.movementY) > 4) clearTimeout(press); }));
  // Shift+F10 or the Menu key on the reply opens it at the reply's corner
  c.on(msg, 'keydown', e => { if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu'){ e.preventDefault(); const b = msg.getBoundingClientRect(), rb = root.getBoundingClientRect(); openAt(b.left - rb.left + 16, b.top - rb.top + 36, true); } });
  // a press anywhere else closes it
  c.on(document, 'pointerdown', e => { if (open && !pop.contains(e.target) && !sub.contains(e.target)) close(); }, true);
  // the submenu opens as the pointer rests on its row, and shuts when it moves to another row
  let hov = 0;
  c.on(pop, 'pointerover', e => { const it = e.target.closest('.mi'); if (!it) return; clearTimeout(hov); if (it === subRow){ if (!subOpen) hov = setTimeout(() => openSub(), 160); } else if (subOpen) hov = setTimeout(() => closeSub(), 200); });
  // the keys: up and down wrap, Home and End, a letter jumps to the next item starting with it, Right opens the submenu and Left
  // or Escape shuts it, Escape shuts the menu and gives the focus back to the reply, Tab shuts it
  const keys = (m, inSub) => c.on(m, 'keydown', e => {
    const its = items(m), i = its.indexOf(document.activeElement), k = e.key;
    let to = null;
    if (k === 'ArrowDown') to = its[(i + 1) % its.length]; else if (k === 'ArrowUp') to = its[(i - 1 + its.length) % its.length];
    else if (k === 'Home') to = its[0]; else if (k === 'End') to = its[its.length - 1];
    else if (k === 'ArrowRight' && !inSub && document.activeElement === subRow){ e.preventDefault(); openSub(true); return; }
    else if ((k === 'ArrowLeft' || k === 'Escape') && inSub){ e.preventDefault(); e.stopPropagation(); closeSub(true); return; }
    else if (k === 'Escape'){ e.preventDefault(); e.stopPropagation(); close(true); return; }
    else if (k === 'Tab'){ close(); return; }
    else if (k.length === 1 && /\S/.test(k)){ const n = its.length, from = Math.max(i, 0); for (let j = 1; j <= n; j++){ const it = its[(from + j) % n]; if (it.textContent.trim().toLowerCase().startsWith(k.toLowerCase())){ to = it; break; } } }
    if (to){ e.preventDefault(); to.focus({ preventScroll:true }); if (!inSub && to !== subRow) closeSub(); }
  });
  keys(pop, false); keys(sub, true);
  // what each item does
  const say = t => { swap(note, t); };
  const words = t => t.split(' ').map((w, i, a) => `<span class="w" style="animation-delay:${i * 45}ms">${w}${i < a.length - 1 ? ' ' : ''}</span>`).join('');
  const ASK = {
    explain:['Explanation', 'info', 'On a phone the keyboard takes the lower half of the screen. The Continue button sits there, so people have to close the keyboard to find it, and many leave instead.'],
    sum:['Summary', 'list', 'Mobile shoppers drop at the address step because the keyboard hides Continue. Moving it up could win back about 300 orders a week.'],
    rewrite:['Shorter', 'edit', 'Mobile checkout loses people at the address step: the keyboard hides Continue. Move it up to win back about 300 orders a week.']
  };
  let asking = 0;
  const ask = k => c.run(async () => {
    const id = ++asking, [lab, ic, text] = ASK[k];
    out.innerHTML = `<div class="cxm-res"><span class="k">${I(ic,'xs')}<span class="shim">Writing a ${lab.toLowerCase()}</span></span><span class="b"></span></div>`; enter(out.firstElementChild);
    await c.wait(900); if (id !== asking) return;
    const k2 = $('.k span', out); k2.classList.remove('shim'); k2.textContent = lab;
    const b = $('.b', out), ws = text.split(' ');
    for (let i = 1; i <= ws.length; i++){ if (id !== asking) return; b.textContent = ws.slice(0, i).join(' '); await c.wait(55); }
  });
  let pinned = false;
  const act = it => {
    const a = it.dataset.a;
    if (a === 'ask'){ subOpen ? closeSub(true) : openSub(true); return; }
    if (a === 'follow'){ const on = it.getAttribute('aria-checked') !== 'true'; it.setAttribute('aria-checked', on); say(on ? 'You will hear about replies to this' : 'You will not hear about replies'); return; }
    // deleting asks again in the same row; the second press deletes, with Undo
    if (a === 'del'){
      if (!del.classList.contains('ask')){ del.classList.add('ask'); $('.t', del).textContent = 'Press again to delete'; del.setAttribute('aria-label', 'Delete reply: press again to confirm'); return; }
      close(); hold(root); const was = msg.innerHTML; msg.hidden = true;
      const g = html(`<div class="cxm-gone">Reply deleted<button class="pill ghost sm">Undo</button></div>`); root.insertBefore(g, note); enter(g); say('');
      $('button', g).onclick = () => { g.remove(); msg.hidden = false; msg.innerHTML = was; release(root); msg.focus({ preventScroll:true }); };
      return;
    }
    close(true);
    if (a === 'copy'){ try { navigator.clipboard.writeText(tx.textContent).catch(() => {}); } catch(e){} say('Copied to clipboard'); }
    if (a === 'quote') say('Quoted in your reply');
    if (a === 'pin'){ pinned = !pinned; $('.cxm-pin', r).hidden = !pinned; $('.t', $('[data-a="pin"]', pop)).textContent = pinned ? 'Unpin' : 'Pin to thread'; say(pinned ? 'Pinned to the thread' : 'Unpinned'); }
    if (ASK[a]) ask(a);
  };
  [pop, sub].forEach(m => c.on(m, 'click', e => { const it = e.target.closest('.mi'); if (it) act(it); }));
  // the preview's story: right-click the reply, ask the agent for a summary, and it streams in under the reply
  if (r === c.st) c.story(async s => {
    await s.beat(3);
    await s.point(tx); await s.beat(1);
    const h = $('.hand', c.st).getBoundingClientRect();
    tx.dispatchEvent(new MouseEvent('contextmenu', { bubbles:true, cancelable:true, clientX:h.left + 3, clientY:h.top + 2, button:2 }));
    await s.beat(2);
    await s.point(subRow, false); openSub(); await s.beat(2);
    await s.tap($('[data-a="sum"]', sub)); s.away();
    await s.until(() => !asking || ($('.b', out) && $('.b', out).textContent.length > 120)); await s.beat(6);
  });
});
06

Questions

What people ask about it.

Can I use it on something other than a reply?
Yes. children is whatever you right-click, and targetClassName replaces the reply's panel classes with your own.
How do I make a checkable item?
Give the item checked (true or false). It becomes a menuitemcheckbox with a check mark, and onSelect tells you it was pressed so you can flip it.
Does it replace the browser's own menu everywhere?
No, only on the target. Everywhere else a right-click still opens the browser's menu.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

BannerAn announcement on a tint of its tone, with its own line drawing.
CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
Invite to collaborateAdd teammates by email as chips, set their role, send.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
All 12 in Collaboration →