Skip to content
goodtabs UI
BrowseBrowse components

Context actions

A right click or long press menu on any region, with groups, icons and shortcuts.

npx shadcn@latest add https://ui.goodtabs.co/r/context-actions.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/context-actions

Q3 report.pdf

Right click this card

A file tile. Right click or long press for Rename, Move to and Delete.

With checkbox items

Right click to change the view

grid, hidden off

Two toggles under a Refresh action. The tile reflects their state.

On a list row

  • Mira ChenDesign review notes
  • VercelDeployment ready: goodtabs
  • Tom OkaforRe: launch checklist

Each inbox row carries its own menu with single-key shortcut hints.

Usage

import { ContextActions } from "@/components/ui/context-actions"

<ContextActions
  items={[
    { type: "item", label: "Rename", icon: <PencilIcon />, shortcut: "R", onSelect: rename },
    { type: "checkbox", label: "Pinned", checked: pinned, onCheckedChange: setPinned },
    { type: "separator" },
    { type: "item", label: "Delete", onSelect: remove, destructive: true },
  ]}
>
  <FileTile />
</ContextActions>

Props

PropTypeDefaultDescription
items({ type: "item"; label; icon?; shortcut?; onSelect; destructive? } | { type: "checkbox"; label; checked; onCheckedChange } | { type: "separator" } | { type: "sub"; label; icon?; items })[]The menu, in order. Separators split items into groups.
childrenReactNodeThe region that answers to a right click or a long press.
classNamestringClass for the wrapper around children.

Source

context-actions/context-actions.tsx
"use client";

import * as React from "react";
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";

export type ContextActionsItem =
  | {
      type: "item";
      /** Menu item text. */
      label: string;
      /** Icon on the left, a lucide element. */
      icon?: React.ReactNode;
      /** Shortcut hint on the right, for example "⌘C". Shown only, not bound. */
      shortcut?: string;
      /** Runs when the item is chosen. */
      onSelect: () => void;
      /** Styles the item for actions that cannot be undone. */
      destructive?: boolean;
    }
  | {
      type: "checkbox";
      /** Menu item text. */
      label: string;
      /** Whether the box is ticked. */
      checked: boolean;
      /** Called with the next state. */
      onCheckedChange: (checked: boolean) => void;
    }
  | { type: "separator" }
  | {
      type: "sub";
      /** Submenu trigger text. */
      label: string;
      /** Icon on the left, a lucide element. */
      icon?: React.ReactNode;
      /** Items of the submenu. */
      items: ContextActionsItem[];
    };

type ContextActionsProps = Omit<React.ComponentProps<typeof ContextMenu>, "children"> & {
  /** The menu, in order. Separators split items into groups. */
  items: ContextActionsItem[];
  /** The region that answers to a right click or a long press. */
  children: React.ReactNode;
  /** Class for the trigger wrapper around children. */
  className?: string;
};

const itemClass =
  "h-8 gap-2 rounded-sm px-2 py-0 focus:bg-muted focus:text-foreground data-open:bg-muted data-open:text-foreground";

/** Splits a flat list into runs between separators so each run gets a group. */
function groups(items: ContextActionsItem[]) {
  const out: ContextActionsItem[][] = [[]];
  for (const item of items) {
    if (item.type === "separator") out.push([]);
    else out[out.length - 1].push(item);
  }
  return out.filter((g) => g.length);
}

function Shortcut({ children }: { children: string }) {
  return (
    <kbd className="ml-auto inline-grid h-5 min-w-5 place-items-center rounded-sm border bg-background px-1 font-mono text-xs leading-none text-muted-foreground">
      {children}
    </kbd>
  );
}

function Items({ items }: { items: ContextActionsItem[] }) {
  return (
    <>
      {groups(items).map((group, i) => (
        <React.Fragment key={i}>
          {i > 0 && <ContextMenuSeparator />}
          <ContextMenuGroup>
            {group.map((item, j) => {
              if (item.type === "sub") {
                return (
                  <ContextMenuSub key={j}>
                    <ContextMenuSubTrigger className={itemClass}>
                      {item.icon && <span className="shrink-0 text-muted-foreground [&_svg]:size-4">{item.icon}</span>}
                      {item.label}
                    </ContextMenuSubTrigger>
                    <ContextMenuSubContent className="min-w-40 rounded-md">
                      <Items items={item.items} />
                    </ContextMenuSubContent>
                  </ContextMenuSub>
                );
              }
              if (item.type === "checkbox") {
                return (
                  <ContextMenuCheckboxItem
                    key={j}
                    checked={item.checked}
                    onCheckedChange={item.onCheckedChange}
                    className={cn(itemClass, "pr-8")}
                  >
                    {item.label}
                  </ContextMenuCheckboxItem>
                );
              }
              if (item.type === "item") {
                return (
                  <ContextMenuItem
                    key={j}
                    variant={item.destructive ? "destructive" : "default"}
                    onSelect={() => item.onSelect()}
                    className={itemClass}
                  >
                    {item.icon && (
                      <span
                        className={cn(
                          "shrink-0 [&_svg]:size-4",
                          item.destructive ? "text-destructive" : "text-muted-foreground",
                        )}
                      >
                        {item.icon}
                      </span>
                    )}
                    {item.label}
                    {item.shortcut && <Shortcut>{item.shortcut}</Shortcut>}
                  </ContextMenuItem>
                );
              }
              return null;
            })}
          </ContextMenuGroup>
        </React.Fragment>
      ))}
    </>
  );
}

/**
 * A right click (or long press) menu on any region. Same items as the actions menu,
 * plus checkbox items for toggles.
 */
export function ContextActions({ items, children, className, ...props }: ContextActionsProps) {
  return (
    <ContextMenu {...props}>
      <ContextMenuTrigger asChild>
        <div data-slot="context-actions" className={cn("select-none", className)}>
          {children}
        </div>
      </ContextMenuTrigger>
      <ContextMenuContent className="min-w-48 rounded-md border ring-0">
        <Items items={items} />
      </ContextMenuContent>
    </ContextMenu>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/context-actions/prompt.md, or the Copy AI prompt button above.

When to use

  • Power user actions on tiles, rows, canvases and editors where a visible button would be noise
  • When the same actions already exist elsewhere and the menu is a shortcut to them
  • Toggles that belong to the region, such as view options, as checkbox items

When not to use

  • As the only way to reach an action; right click is not discoverable and not everyone can use it
  • On small tap targets or inline text, where a long press selects instead
  • For a visible trigger; use the actions menu

Agent notes

  • It takes functions (onSelect, onCheckedChange), so the component rendering it must be a client component
  • Long press on touch comes from radix, about 700ms; the wrapper is select-none so the press does not highlight text
  • The wrapper is a plain div; size it with className and let children fill it
  • Shortcut strings are hints only; bind the keys yourself and keep the two in sync