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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. | |
| children | ReactNode | The region that answers to a right click or a long press. | |
| className | string | Class 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