Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
items({ type: "item"; label; icon?; shortcut?; onSelect; destructive? } | { type: "separator" } | { type: "sub"; label; icon?; items })[]The menu, in order. Separators split items into groups.
labelstring"More actions"Accessible name of the default ellipsis trigger.
triggerReactNodeYour 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