ComponentsActions

Context menu

The row menu, opened with a right-click, a long press or Shift+F10. It holds the selection bar's actions for the rows it opened on, plus Open and Copy link for one row.

Playground

Right-click the row, long-press it on a touch screen, or focus it and press Shift+F10. Turn on disabled and the browser's own menu shows instead. The code under the preview lists only the props you changed.

Halcyon Bank
States
import { ContextMenu } from '@/ui/context-menu';<ContextMenu />

One row

Right-click a company. The menu opens at the pointer with Open and Copy link first, then actions for this record: Find website shows only for a company without one. The row takes the current fill while its menu is open.

Halcyon Bankhalcyonbank.com
Orbital Freight
Northbridge Talentnorthbridge.io
import { ArchiveIcon, ArrowSquareOutIcon, GlobeIcon, LinkIcon } from '@phosphor-icons/react';
import { CompanyLogo } from '@/ui/company-logo';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '@/ui/context-menu';

const companies = [
  { name: 'Halcyon Bank', website: 'halcyonbank.com' },
  { name: 'Orbital Freight', website: '' },
  { name: 'Northbridge Talent', website: 'northbridge.io' },
];

export default function ContextMenuDefault() {
  return (
    <div className="w-90 rounded-large bg-surface shadow-raised">
      {companies.map((company) => (
        <ContextMenu key={company.name}>
          <ContextMenuTrigger
            render={<div tabIndex={0} />}
            className="flex h-9 items-center gap-100 border-b border-border px-150 text-body font-medium last:border-b-0"
          >
            <CompanyLogo name={company.name} />
            <span className="grow">{company.name}</span>
            <span className="font-normal text-text-subtle">{company.website}</span>
          </ContextMenuTrigger>
          <ContextMenuContent aria-label={`Actions for ${company.name}`}>
            <ContextMenuItem>
              <ArrowSquareOutIcon />
              Open
            </ContextMenuItem>
            <ContextMenuItem>
              <LinkIcon />
              Copy link
            </ContextMenuItem>
            {!company.website && (
              <ContextMenuItem>
                <GlobeIcon />
                Find website
              </ContextMenuItem>
            )}
            <ContextMenuSeparator />
            <ContextMenuItem>
              <ArchiveIcon />
              Archive
            </ContextMenuItem>
          </ContextMenuContent>
        </ContextMenu>
      ))}
    </div>
  );
}

Several rows and a submenu

On a selection, the menu acts on every selected row and says how many: Archive 3 applications. Open and Copy link drop out. The submenu opens to the right with its first item level with its parent, and the parent keeps the current fill. Danger items go last, after a separator.

3 applications selected. Right-click here.
import { ArchiveIcon, ArrowsLeftRightIcon, TrashIcon } from '@phosphor-icons/react';
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/ui/context-menu';

export default function ContextMenuSelection() {
  return (
    <ContextMenu>
      <ContextMenuTrigger
        render={<div tabIndex={0} />}
        className="flex h-24 w-90 items-center justify-center rounded-large bg-background-selected text-body font-medium text-brand-bold"
      >
        3 applications selected. Right-click here.
      </ContextMenuTrigger>
      <ContextMenuContent aria-label="Actions for 3 applications">
        <ContextMenuSub>
          <ContextMenuSubTrigger>
            <ArrowsLeftRightIcon />
            Move 3 applications to stage
          </ContextMenuSubTrigger>
          <ContextMenuSubContent>
            <ContextMenuItem>CV sent</ContextMenuItem>
            <ContextMenuItem>Interview</ContextMenuItem>
            <ContextMenuItem>Offer</ContextMenuItem>
            <ContextMenuItem>Rejected</ContextMenuItem>
          </ContextMenuSubContent>
        </ContextMenuSub>
        <ContextMenuItem>
          <ArchiveIcon />
          Archive 3 applications
        </ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem variant="destructive">
          <TrashIcon />
          Delete 3 applications
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

When things go wrong

Long and non-Latin names cut off on the row and wrap in the menu. When the selected rows share no stage, the submenu holds one disabled line that says why instead of an empty list. A failed action says what happened in an error toast that stays until closed. The menu closes on the first press, so an item never runs twice.

王小明, Zoë O'Brien-Smith and Nguyễn Thị Minh Khai selected
import { ArchiveIcon, ArrowsLeftRightIcon } from '@phosphor-icons/react';
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from '@/ui/context-menu';
import { toast } from '@/ui/toast';

// Rows at different stages share none, so the submenu says why instead of listing stages. A failed archive says so in a toast that stays.
export default function ContextMenuUnhappy() {
  return (
    <ContextMenu>
      <ContextMenuTrigger
        render={<div tabIndex={0} />}
        className="flex h-9 w-90 min-w-0 items-center rounded-large bg-background-selected px-150 text-body font-medium text-brand-bold"
      >
        <span className="truncate">王小明, Zoë O&apos;Brien-Smith and Nguyễn Thị Minh Khai selected</span>
      </ContextMenuTrigger>
      <ContextMenuContent aria-label="Actions for 3 applications">
        <ContextMenuSub>
          <ContextMenuSubTrigger>
            <ArrowsLeftRightIcon />
            Move 3 applications to stage
          </ContextMenuSubTrigger>
          <ContextMenuSubContent>
            <ContextMenuItem disabled>These applications share no stage.</ContextMenuItem>
          </ContextMenuSubContent>
        </ContextMenuSub>
        <ContextMenuItem
          onClick={() => toast.add({ type: 'error', title: 'Couldn’t archive 3 applications', description: 'Check your connection and try again.' })}
        >
          <ArchiveIcon />
          Archive 3 applications
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Motion

The menu uses the Popup motion. Press Replay to close and open it again.

Halcyon Bank
import { ArrowSquareOutIcon, LinkIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button } from '@/ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/ui/context-menu';

// Replay closes the menu, then opens it again at the row, so the 120ms exit and the 160ms entry run back to back.
export default function ContextMenuMotion() {
  const [open, setOpen] = useState(false);
  const replay = () => {
    setOpen(false);
    setTimeout(() => setOpen(true), 400);
  };
  return (
    <div className="flex items-center gap-150">
      <ContextMenu open={open} onOpenChange={setOpen}>
        <ContextMenuTrigger render={<div tabIndex={0} />} className="flex h-9 w-60 items-center rounded-large bg-surface px-150 text-body font-medium shadow-raised">
          Halcyon Bank
        </ContextMenuTrigger>
        <ContextMenuContent aria-label="Actions for Halcyon Bank">
          <ContextMenuItem>
            <ArrowSquareOutIcon />
            Open
          </ContextMenuItem>
          <ContextMenuItem>
            <LinkIcon />
            Copy link
          </ContextMenuItem>
        </ContextMenuContent>
      </ContextMenu>
      <Button variant="ghost" onClick={replay}>
        Replay
      </Button>
    </div>
  );
}

On this page