React context menu
A right click menu after the APG menu pattern. Opens by right click, long press, the context menu key or Shift F10, with submenus, checkboxes, radios, shortcuts and a destructive action that asks once more.
The right actions at the pointer
A context menu offers the actions that fit the thing under the pointer. This one opens by right click, long press, the context menu key and Shift F10, nests submenus that open with Right and close with Left, jumps by typeahead, stays inside the window and returns focus on close.
File list uses one menu for a whole list, each row carries data-context, so the menu names the file and the action knows it, and moving to the bin asks once more. Design canvas brings edit commands with shortcuts, submenus to arrange and align, checkboxes that stay open for several toggles and a radio group for the zoom. Spelling in text puts suggestions for a misspelled word first, then edit commands, formatting with checkboxes and a translate submenu. Chat messages is made for phones as much as for the mouse, a long press opens the menu, pinning stays as a check and deleting for everyone asks once more.
Put the most used action first and the destructive one last, separated by a line. Every action in a context menu should also be reachable another way, many people never right click. Show shortcuts in the menu, it is where people learn them.
Variants
Details
What Context menu brings along, read from the registry and the source.
- Category
- Overlays
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/context-menu.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ContextMenuProps interface from components/ui/context-menu.tsx, with the comments from the code.
itemsrequiredContextMenuItem[]onActionoptional(id: string, context: string | null, detail?: boolean | string) => voidCalled with the id of a chosen item (checkbox: id and the new state, radio: group and value) and the `data-context` value of the element that was right clicked, so one menu serves a whole list.
titleoptionalstring | ((context: string | null) => string | undefined)A heading inside the menu, e.g. the name of the file. A function gets the data-context value.
showHintoptionalbooleanShow a small line under the area that tells how to open the menu
defaultOpenoptionalbooleanOpen right away, at this point inside the area (px from its top left), for previews
defaultPositionoptional{ x: number; y: number }longPressoptionalbooleanOpen after a long press on touch screens, on by default
childrenrequiredReact.ReactNodeThe area. Elements inside with data-context="…" tell onAction what was clicked.
Built-in texts
Context menu writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| menu | English Actions | German Aktionen |
| hint | English Right click, long press, or press Shift F10 for actions. | German Rechtsklick, lange drücken oder Umschalt F10 für Aktionen. |
| back | English Back | German Zurück |
Accessibility
Read from the source of the component.
- Context menu handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Page up, Page down, Tab and Space itself.
- Sets aria-checked, aria-describedby, aria-disabled, aria-expanded, aria-haspopup, aria-keyshortcuts, aria-label and aria-orientation, so screen readers announce purpose and state.
- Uses the roles menu, presentation, role and separator.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 492 lines
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
/*
* Name note: dropdown-menu.tsx also exports a small ContextMenu (right click only, flat MenuItem list).
* The CLI installs each component into its own file and neither imports the other, so both live
* side by side in one project. To use both in one file, rename on import:
* import { ContextMenu as RichContextMenu } from '@/components/ui/context-menu';
* This one adds long press, Shift F10, submenus and checkbox and radio items.
*/
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const TEXTS = {
en: { menu: 'Actions', hint: 'Right click, long press, or press Shift F10 for actions.', back: 'Back' },
de: { menu: 'Aktionen', hint: 'Rechtsklick, lange drücken oder Umschalt F10 für Aktionen.', back: 'Zurück' },
};
export type ContextMenuItem =
| {
type?: 'item';
/** Passed to onAction */
id?: string;
label: string;
icon?: IconName;
/** Shown on the right and set as aria-keyshortcuts, e.g. ⌘C */
shortcut?: string;
description?: string;
disabled?: boolean;
destructive?: boolean;
/** A destructive action asks once more in place: the label turns into this text, a second press confirms */
confirm?: string;
}
| { type: 'checkbox'; id: string; label: string; checked?: boolean; icon?: IconName; shortcut?: string; disabled?: boolean }
| { type: 'radio'; group: string; value: string; label: string; checked?: boolean; disabled?: boolean }
| { type: 'submenu'; label: string; icon?: IconName; items: ContextMenuItem[]; disabled?: boolean }
| { type: 'label'; label: string }
| { type: 'separator' };
export interface ContextMenuProps {
items: ContextMenuItem[];
/**
* Called with the id of a chosen item (checkbox: id and the new state, radio: group and value) and the
* `data-context` value of the element that was right clicked, so one menu serves a whole list.
*/
onAction?: (id: string, context: string | null, detail?: boolean | string) => void;
/** A heading inside the menu, e.g. the name of the file. A function gets the data-context value. */
title?: string | ((context: string | null) => string | undefined);
/** Show a small line under the area that tells how to open the menu */
showHint?: boolean;
/** Open right away, at this point inside the area (px from its top left), for previews */
defaultOpen?: boolean;
defaultPosition?: { x: number; y: number };
/** Open after a long press on touch screens, on by default */
longPress?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
/** The area. Elements inside with data-context="…" tell onAction what was clicked. */
children: React.ReactNode;
}
type Point = { x: number; y: number };
const MARGIN = 8;
/* Items arrive one after another from the pointer */
const CSS =
'.kitfx-cm{transform-origin:var(--origin,top left);animation:kitfx-cm-in .2s cubic-bezier(.34,1.3,.64,1) both}' +
'.kitfx-cm-item{animation:kitfx-cm-item .28s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 18ms + 20ms)}' +
'@keyframes kitfx-cm-in{from{opacity:0;transform:scale(.94)}}@keyframes kitfx-cm-item{from{opacity:0;transform:translateY(-3px)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-cm,.kitfx-cm-item{animation:none}}';
function initialState(items: ContextMenuItem[], checks: Record<string, boolean> = {}, radios: Record<string, string> = {}) {
for (const it of items) {
if (it.type === 'checkbox') checks[it.id] = Boolean(it.checked);
if (it.type === 'radio' && it.checked) radios[it.group] = it.value;
if (it.type === 'submenu') initialState(it.items, checks, radios);
}
return { checks, radios };
}
const isAction = (it: ContextMenuItem) => it.type !== 'label' && it.type !== 'separator';
/**
* A context menu after the APG menu pattern, opened by right click, long press, the context menu key or
* Shift F10. Arrows, Home, End and typeahead, submenus with Right and Left, checkbox and radio items,
* shortcuts, a destructive action that asks once more, kept inside the window, focus back on close.
* <ContextMenu items={[{ id: 'copy', label: 'Copy', shortcut: '⌘C' }, …]} onAction={(id, ctx) => …}><FileList /></ContextMenu>
*/
export function ContextMenu({ items, onAction, title, showHint, defaultOpen, defaultPosition, longPress = true, labels, className, children }: ContextMenuProps) {
const t = useTexts(TEXTS, labels);
const id = useId();
const areaRef = useRef<HTMLDivElement>(null);
// Where it opened, relative to the area, so the menu stays put while the page scrolls
const [at, setAt] = useState<(Point & { context: string | null; keyboard: boolean; preview?: boolean }) | null>(null);
const [mounted, setMounted] = useState(false);
const returnTo = useRef<HTMLElement | null>(null);
const [state, setState] = useState(() => initialState(items));
useEffect(() => {
setMounted(true);
if (!defaultOpen || !areaRef.current) return;
const pos = defaultPosition ?? { x: 24, y: 24 };
const r = areaRef.current.getBoundingClientRect();
const under = document.elementFromPoint(r.left + pos.x, r.top + pos.y)?.closest('[data-context]') ?? areaRef.current.querySelector('[data-context]');
setAt({ ...pos, context: under?.getAttribute('data-context') ?? null, keyboard: false, preview: true });
}, [defaultOpen, defaultPosition?.x, defaultPosition?.y]);
const openAt = (clientX: number, clientY: number, target: EventTarget | null, keyboard: boolean) => {
const area = areaRef.current;
if (!area) return;
const r = area.getBoundingClientRect();
const ctxEl = (target as HTMLElement | null)?.closest?.('[data-context]');
returnTo.current = (document.activeElement as HTMLElement) ?? area;
setAt({ x: clientX - r.left, y: clientY - r.top, context: ctxEl?.getAttribute('data-context') ?? null, keyboard });
};
const close = useCallback((focusBack: boolean) => {
setAt(null);
if (focusBack) (returnTo.current && document.contains(returnTo.current) ? returnTo.current : areaRef.current)?.focus({ preventScroll: true });
}, []);
/* ---------- long press on touch ---------- */
const press = useRef<{ timer: number; x: number; y: number } | null>(null);
const cancelPress = () => {
if (press.current) window.clearTimeout(press.current.timer);
press.current = null;
};
const choose = (it: ContextMenuItem, context: string | null) => {
if (it.type === 'checkbox') {
const next = !state.checks[it.id];
setState((s) => ({ ...s, checks: { ...s.checks, [it.id]: next } }));
onAction?.(it.id, context, next);
return false;
}
if (it.type === 'radio') {
setState((s) => ({ ...s, radios: { ...s.radios, [it.group]: it.value } }));
onAction?.(it.group, context, it.value);
return true;
}
if (it.type === undefined || it.type === 'item') onAction?.(it.id ?? it.label, context);
return true;
};
const heading = at ? (typeof title === 'function' ? title(at.context) : title) : undefined;
return (
<>
<div
ref={areaRef}
tabIndex={0}
aria-describedby={`${id}-hint`}
aria-haspopup="menu"
onContextMenu={(e) => {
e.preventDefault();
cancelPress();
if ((e.target as HTMLElement).closest?.('[data-cm-panel]')) return;
// A context menu key press reports 0,0: open at the focused element instead
if (e.clientX === 0 && e.clientY === 0) {
const el = (document.activeElement as HTMLElement) ?? e.currentTarget;
const r = el.getBoundingClientRect();
openAt(r.left + Math.min(24, r.width / 2), r.top + Math.min(r.height, 32), el, true);
} else openAt(e.clientX, e.clientY, e.target, false);
}}
onKeyDown={(e) => {
if (((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') && !(e.target as HTMLElement).closest?.('[data-cm-panel]')) {
e.preventDefault();
const el = e.target as HTMLElement;
const r = el.getBoundingClientRect();
openAt(r.left + Math.min(24, r.width / 2), r.top + Math.min(r.height, 32), el, true);
}
}}
onPointerDown={(e) => {
if (!longPress || e.pointerType !== 'touch' || (e.target as HTMLElement).closest?.('[data-cm-panel]')) return;
const { clientX: x, clientY: y, target } = e;
cancelPress();
press.current = { x, y, timer: window.setTimeout(() => openAt(x, y, target, false), 520) };
}}
onPointerMove={(e) => press.current && Math.hypot(e.clientX - press.current.x, e.clientY - press.current.y) > 10 && cancelPress()}
onPointerUp={cancelPress}
onPointerCancel={cancelPress}
className={cn('relative rounded-xl outline-none [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring', className)}
>
{children}
{mounted && at && (
<MenuPanel
items={items}
area={areaRef}
point={at}
depth={0}
label={heading ?? t.menu}
title={heading}
autoFocus={at.keyboard ? 'first' : at.preview ? 'skip' : 'none'}
state={state}
onChoose={(it) => choose(it, at.context)}
onCloseAll={close}
labels={t}
/>
)}
</div>
<p id={`${id}-hint`} className={cn(showHint ? 'mt-3 flex items-start gap-1.5 text-[13px] text-pretty text-muted-foreground' : 'sr-only')}>
{showHint && <Icon name="mouse" size={14} className="mt-0.5" />}
{t.hint}
</p>
</>
);
}
interface PanelProps {
items: ContextMenuItem[];
area?: React.RefObject<HTMLDivElement | null>;
point?: Point;
/** For a submenu: the item it belongs to */
parent?: HTMLElement;
depth: number;
label: string;
title?: string;
autoFocus: 'first' | 'none' | 'skip';
state: { checks: Record<string, boolean>; radios: Record<string, string> };
/** true closes the whole menu */
onChoose: (it: ContextMenuItem) => boolean;
onCloseAll: (focusBack: boolean) => void;
onBack?: () => void;
labels: (typeof TEXTS)['en'];
}
function MenuPanel({ items, area, point, parent, depth, label, title, autoFocus, state, onChoose, onCloseAll, onBack, labels }: PanelProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ x: number; y: number; origin: string } | null>(null);
const [sub, setSub] = useState<{ index: number; el: HTMLElement; focus: 'first' | 'none' } | null>(null);
const [armed, setArmed] = useState<number | null>(null);
const typed = useRef({ text: '', at: 0 });
const hoverTimer = useRef<number | undefined>(undefined);
const nodes = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(':scope > [data-cm-item]:not([aria-disabled="true"])') ?? []);
// Place it at the pointer or beside its parent item, flipped and clamped into the window
useIsoLayoutEffect(() => {
const place = () => {
const el = ref.current;
if (!el) return;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = el.offsetWidth;
const h = el.offsetHeight;
let x: number;
let y: number;
let ox = 'left';
let oy = 'top';
if (parent) {
const r = parent.getBoundingClientRect();
x = r.right + 2;
y = r.top - 6;
if (x + w > vw - MARGIN) {
x = r.left - w - 2;
ox = 'right';
}
// A narrow screen has no room on either side: the submenu covers its parent instead, a bit lower
if (x < MARGIN) {
x = Math.min(Math.max(r.left + 12, MARGIN), vw - MARGIN - w);
y = r.bottom - 4;
ox = 'left';
}
} else {
const a = area?.current?.getBoundingClientRect();
x = (a?.left ?? 0) + (point?.x ?? 0);
y = (a?.top ?? 0) + (point?.y ?? 0);
if (x + w > vw - MARGIN) {
x = Math.max(MARGIN, x - w);
ox = 'right';
}
if (y + h > vh - MARGIN && y - h >= MARGIN) {
y -= h;
oy = 'bottom';
}
}
x = Math.min(Math.max(x, MARGIN), Math.max(MARGIN, vw - MARGIN - w));
y = Math.min(Math.max(y, MARGIN), Math.max(MARGIN, vh - MARGIN - h));
setPos({ x: Math.round(x), y: Math.round(y), origin: `${oy} ${ox}` });
};
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [parent, point?.x, point?.y]);
useEffect(() => {
const id = requestAnimationFrame(() => {
if (autoFocus === 'first') nodes()[0]?.focus({ preventScroll: true });
else if (autoFocus === 'none' && depth === 0) ref.current?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(id);
}, []);
// The root panel closes on Escape anywhere and on a press outside every panel
useEffect(() => {
if (depth !== 0) return;
const onDown = (e: PointerEvent) => {
if ((e.target as HTMLElement).closest?.('[data-cm-panel]')) return;
onCloseAll(false);
};
const onBlurWindow = () => onCloseAll(false);
// A menu opened for a preview has no focus yet, Escape still closes it
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && !ref.current?.contains(document.activeElement) && onCloseAll(false);
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown, true);
window.addEventListener('blur', onBlurWindow);
return () => {
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
window.removeEventListener('blur', onBlurWindow);
};
}, [depth, onCloseAll]);
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
const move = (to: number | 'first' | 'last') => {
const list = nodes();
if (!list.length) return;
const i = list.indexOf(document.activeElement as HTMLElement);
if (i < 0 && typeof to === 'number') to = to > 0 ? 'first' : 'last';
const n = to === 'first' ? 0 : to === 'last' ? list.length - 1 : (i + to + list.length) % list.length;
list[n].focus({ preventScroll: true });
};
const activate = (it: ContextMenuItem, i: number, el: HTMLElement, keyboard: boolean) => {
if (!isAction(it) || ('disabled' in it && it.disabled)) return;
if (it.type === 'submenu') {
setSub({ index: i, el, focus: keyboard ? 'first' : 'none' });
return;
}
if ((it.type === undefined || it.type === 'item') && it.confirm && armed !== i) {
setArmed(i);
return;
}
if (onChoose(it)) onCloseAll(true);
};
const onKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
const list = nodes();
const current = document.activeElement as HTMLElement;
const index = Number(current?.dataset.index);
const it = Number.isFinite(index) ? items[index] : undefined;
if (e.key === 'ArrowDown') (e.preventDefault(), move(1));
else if (e.key === 'ArrowUp') (e.preventDefault(), move(-1));
else if (e.key === 'Home' || e.key === 'PageUp') (e.preventDefault(), move('first'));
else if (e.key === 'End' || e.key === 'PageDown') (e.preventDefault(), move('last'));
else if (e.key === 'ArrowRight' && it?.type === 'submenu') (e.preventDefault(), activate(it, index, current, true));
else if (e.key === 'ArrowLeft' && onBack) (e.preventDefault(), onBack());
else if (e.key === 'Escape') {
e.preventDefault();
if (onBack) onBack();
else onCloseAll(true);
} else if (e.key === 'Tab') {
e.preventDefault();
onCloseAll(true);
} else if ((e.key === 'Enter' || e.key === ' ') && it) {
e.preventDefault();
activate(it, index, current, true);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const now = Date.now();
typed.current = { text: (now - typed.current.at > 600 ? '' : typed.current.text) + e.key.toLowerCase(), at: now };
const start = list.indexOf(current);
const ordered = [...list.slice(start + 1), ...list.slice(0, start + 1)];
const hit = typed.current.text.length > 1 ? list.find((n) => n.dataset.label?.startsWith(typed.current.text)) : ordered.find((n) => n.dataset.label?.startsWith(typed.current.text));
hit?.focus({ preventScroll: true });
}
};
return createPortal(
<div
ref={ref}
data-cm-panel=""
role="menu"
aria-label={label}
aria-orientation="vertical"
tabIndex={-1}
onKeyDown={onKeyDown}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className={cn(
'kitfx-cm fixed left-0 top-0 z-50 max-h-[calc(100dvh-16px)] min-w-56 max-w-[calc(100vw-16px)] overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)] outline-none kit-no-scrollbar',
depth > 0 && 'min-w-48',
)}
style={{ transform: `translate(${pos?.x ?? 0}px, ${pos?.y ?? 0}px)`, visibility: pos ? undefined : 'hidden', '--origin': pos?.origin } as React.CSSProperties}
>
<style href="kitfx-cm" precedence="default">
{CSS}
</style>
{title && depth === 0 && (
<div role="presentation" className="kitfx-cm-item -mx-1.5 -mt-1.5 mb-1.5 truncate border-b border-border px-4 py-2.5 text-[13px] font-medium text-foreground">
{title}
</div>
)}
{items.map((it, i) => {
const order = { '--i': i } as React.CSSProperties;
if (it.type === 'separator') return <div key={i} role="separator" className="kitfx-cm-item -mx-1.5 my-1.5 h-px bg-border" style={order} />;
if (it.type === 'label')
return (
<div key={i} role="presentation" style={order} className="kitfx-cm-item px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
{it.label}
</div>
);
const isItem = it.type === undefined || it.type === 'item';
const destructive = isItem && it.destructive;
const isArmed = isItem && armed === i;
const checked = it.type === 'checkbox' ? Boolean(state.checks[it.id]) : it.type === 'radio' ? state.radios[it.group] === it.value : undefined;
const role = it.type === 'checkbox' ? 'menuitemcheckbox' : it.type === 'radio' ? 'menuitemradio' : 'menuitem';
const icon = it.type === 'radio' ? undefined : it.icon;
const shortcut = isItem || it.type === 'checkbox' ? it.shortcut : undefined;
const open = sub?.index === i;
const text = isArmed && isItem ? it.confirm! : it.label;
return (
<div
key={i}
role={role}
tabIndex={-1}
data-cm-item=""
data-index={i}
data-label={it.label.toLowerCase()}
aria-disabled={it.disabled || undefined}
aria-checked={checked}
aria-haspopup={it.type === 'submenu' ? 'menu' : undefined}
aria-expanded={it.type === 'submenu' ? open : undefined}
aria-keyshortcuts={shortcut}
style={order}
onClick={(e) => activate(it, i, e.currentTarget, e.detail === 0)}
onPointerMove={(e) => {
if (e.pointerType !== 'mouse' || it.disabled) return;
if (document.activeElement !== e.currentTarget) e.currentTarget.focus({ preventScroll: true });
window.clearTimeout(hoverTimer.current);
const el = e.currentTarget;
if (it.type === 'submenu' && !open) hoverTimer.current = window.setTimeout(() => setSub({ index: i, el, focus: 'none' }), 140);
else if (it.type !== 'submenu' && sub) hoverTimer.current = window.setTimeout(() => setSub(null), 220);
}}
onBlur={() => isArmed && setArmed(null)}
className={cn(
'kitfx-cm-item group/cm relative flex min-h-9 w-full cursor-pointer select-none items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-foreground outline-none transition-colors max-sm:min-h-11',
'focus:bg-accent aria-disabled:cursor-not-allowed aria-disabled:opacity-45',
open && 'bg-accent',
destructive && 'text-destructive focus:bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)]',
isArmed && 'bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] font-medium focus:bg-[color-mix(in_oklab,var(--destructive)_18%,transparent)]',
)}
>
{(it.type === 'checkbox' || it.type === 'radio') && (
<span aria-hidden className="grid size-4 shrink-0 place-items-center">
<span className={cn('grid place-items-center transition-[scale,opacity] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', checked ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}>
{it.type === 'checkbox' ? <Icon name="check" size={15} strokeWidth={2} /> : <span className="size-[7px] rounded-full bg-current" />}
</span>
</span>
)}
{icon && <Icon name={isArmed ? 'alert-circle' : icon} size={16} className={cn('shrink-0', destructive ? 'text-destructive' : 'text-muted-foreground group-focus/cm:text-foreground')} />}
<span className="min-w-0 flex-1">
<span className="block truncate">{text}</span>
{isItem && it.description && !isArmed && <span className="block truncate text-xs text-muted-foreground">{it.description}</span>}
</span>
{shortcut && !isArmed && <span className="ms-4 shrink-0 text-xs tracking-wide text-subtle-foreground">{shortcut}</span>}
{it.type === 'submenu' && <Icon name="chevron-right" size={15} className="ms-3 shrink-0 text-muted-foreground" />}
</div>
);
})}
{sub && items[sub.index]?.type === 'submenu' && (
<MenuPanel
items={(items[sub.index] as Extract<ContextMenuItem, { type: 'submenu' }>).items}
parent={sub.el}
depth={depth + 1}
label={items[sub.index].type === 'submenu' ? (items[sub.index] as { label: string }).label : labels.menu}
autoFocus={sub.focus}
state={state}
onChoose={onChoose}
onCloseAll={onCloseAll}
onBack={() => {
const el = sub.el;
setSub(null);
el.focus({ preventScroll: true });
}}
labels={labels}
/>
)}
</div>,
document.body,
);
}Frequent questions
How do I install Context menu?
With aiw add @ai-website/context-menu. The command puts components/ui/context-menu.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Context menu have?
4 variants, File list, Design canvas, Spelling in text and Chat messages.
Can I translate the texts of Context menu?
Yes. Context menu carries 3 texts of its own in English and German, such as “Actions”, “Right click, long press, or press Shift F10 for actions.” and “Back”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Context menu?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Context menu with aiw_components and install it with aiw_add @ai-website/context-menu.