Actions menu
A trigger that opens a menu of actions with icons, shortcut hints and submenus.
npx shadcn@latest add https://ui.goodtabs.co/r/actions-menu.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/actions-menu
Q3 report.pdf
An ellipsis button on a file row. Three groups, a Move to submenu and Delete last.
Text trigger
Pass your own trigger, here an outline Actions button with a chevron.
With submenus
GT-142Search drops accents when filtering
Three submenus on an issue row: Move to, Assign to and Label.
Usage
import { ActionsMenu } from "@/components/ui/actions-menu"
<ActionsMenu
label="Actions for Q3 report"
items={[
{ type: "item", label: "Rename", icon: <PencilIcon />, shortcut: "R", onSelect: rename },
{ type: "separator" },
{ type: "sub", label: "Move to", items: [{ type: "item", label: "Archive", onSelect: archive }] },
{ type: "separator" },
{ type: "item", label: "Delete", icon: <Trash2Icon />, onSelect: remove, destructive: true },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ({ type: "item"; label; icon?; shortcut?; onSelect; destructive? } | { type: "separator" } | { type: "sub"; label; icon?; items })[] | The menu, in order. Separators split items into groups. | |
| label | string | "More actions" | Accessible name of the default ellipsis trigger. |
| trigger | ReactNode | Your own trigger element. Defaults to a ghost ellipsis icon button. | |
| align | "start" | "center" | "end" | "end" | Where the menu lines up with the trigger. |
Source
actions-menu/actions-menu.tsx
"use client";
import * as React from "react";
import { EllipsisIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";
export type ActionsMenuItem =
| {
type: "item";
/** Menu item text. */
label: string;
/** Icon on the left, a lucide element. */
icon?: React.ReactNode;
/** Shortcut hint on the right, for example "⌘E". 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: "separator" }
| {
type: "sub";
/** Submenu trigger text. */
label: string;
/** Icon on the left, a lucide element. */
icon?: React.ReactNode;
/** Items of the submenu. */
items: ActionsMenuItem[];
};
type ActionsMenuProps = Omit<React.ComponentProps<typeof DropdownMenu>, "children"> & {
/** The menu, in order. Separators split items into groups. */
items: ActionsMenuItem[];
/** Accessible name of the default trigger. */
label?: string;
/** Your own trigger. Defaults to an ellipsis icon button. */
trigger?: React.ReactNode;
/** Where the menu lines up with the trigger. */
align?: "start" | "center" | "end";
};
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: ActionsMenuItem[]) {
const out: ActionsMenuItem[][] = [[]];
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 Items({ items }: { items: ActionsMenuItem[] }) {
return (
<>
{groups(items).map((group, i) => (
<React.Fragment key={i}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
{group.map((item, j) =>
item.type === "sub" ? (
<DropdownMenuSub key={j}>
<DropdownMenuSubTrigger className={itemClass}>
{item.icon && <span className="shrink-0 text-muted-foreground [&_svg]:size-4">{item.icon}</span>}
{item.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-40 rounded-md border ring-0">
<Items items={item.items} />
</DropdownMenuSubContent>
</DropdownMenuSub>
) : item.type === "item" ? (
<DropdownMenuItem
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 && <Kbd className="ml-auto">{item.shortcut}</Kbd>}
</DropdownMenuItem>
) : null,
)}
</DropdownMenuGroup>
</React.Fragment>
))}
</>
);
}
/**
* An icon or text trigger that opens a menu of actions: groups, icons, shortcut hints,
* submenus and a destructive item at the end.
*/
export function ActionsMenu({ items, label = "More actions", trigger, align = "end", ...props }: ActionsMenuProps) {
return (
<DropdownMenu {...props}>
<DropdownMenuTrigger asChild>
{trigger ?? (
<Button type="button" variant="ghost" size="icon-sm" aria-label={label} className="rounded-full">
<EllipsisIcon aria-hidden="true" />
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-auto min-w-48 rounded-md border ring-0">
<Items items={items} />
</DropdownMenuContent>
</DropdownMenu>
);
}
For AI agents
Hand your agent this page, the markdown at /components/actions-menu/prompt.md, or the Copy AI prompt button above.
When to use
- Secondary actions on a row, card or header that do not deserve their own buttons
- Three to ten actions that fall into natural groups
- When one action moves the thing somewhere: the submenu lists the destinations
When not to use
- One or two actions; show them as buttons
- Choosing a value rather than running an action; use a select
- Actions on a right click; use the context actions
Agent notes
- It takes functions (items[].onSelect), so the component rendering it must be a client component
- Shortcut strings are hints only; bind the keys yourself and keep the two in sync
- Put destructive: true on the last item of the last group; it uses the destructive menu item variant
- A custom trigger must forward refs and props (a shadcn Button does); the default is a ghost icon-sm pill named by label