Skip to content
UI Kit
Accent color

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.

4 variantsOne of 11 components in Overlays

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

File listNew in 1.2

One menu for a whole list. Each row carries data-context, so the menu names the file and the action knows it. Submenus open to the side, or below their item when the screen is narrow, and moving to the bin asks once more.

Design canvasNew in 1.2

Edit commands with shortcuts, submenus to arrange and align, checkboxes that stay open for several toggles and a radio group for the zoom. The canvas follows every choice.

Spelling in textNew in 1.2

Suggestions on a misspelled word come first, then the edit commands, formatting with checkboxes and a translate submenu. Typing a letter jumps to the matching entry.

Chat messagesNew in 1.2

Made for phones as much as for the mouse. A long press on a message opens the menu, pinning stays as a check, deleting for everyone sits in a submenu and asks once more.

Details

What Context menu brings along, read from the registry and the source.

Category
Overlays
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

Props

The ContextMenuProps interface from components/ui/context-menu.tsx, with the comments from the code.

  • itemsrequired
    ContextMenuItem[]
  • onActionoptional
    (id: string, context: string | null, detail?: boolean | string) => void

    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.

  • titleoptional
    string | ((context: string | null) => string | undefined)

    A heading inside the menu, e.g. the name of the file. A function gets the data-context value.

  • showHintoptional
    boolean

    Show a small line under the area that tells how to open the menu

  • defaultOpenoptional
    boolean

    Open right away, at this point inside the area (px from its top left), for previews

  • defaultPositionoptional
    { x: number; y: number }
  • longPressoptional
    boolean

    Open after a long press on touch screens, on by default

  • childrenrequired
    React.ReactNode

    The 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
menuEnglish ActionsGerman Aktionen
hintEnglish Right click, long press, or press Shift F10 for actions.German Rechtsklick, lange drücken oder Umschalt F10 für Aktionen.
backEnglish BackGerman 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
components/ui/context-menu.tsx
'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.

Often searched

Jump to

Copied to the clipboard