Skip to content
goodtabs UI
BrowseBrowse components

Row actions

The kebab menu for a table row: edit, duplicate, extras and a destructive delete.

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

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

PageUpdatedActions
Home2 hours ago
PricingYesterday
Changelog3 days ago

Three rows in a shadcn Table, each with Edit, Duplicate and Delete.

With extra items

Pricing

Yesterday

Copy link and Archive sit after Duplicate, before the separator.

Standalone

The trigger on its own, named Page actions for screen readers.

Usage

import { RowActions } from "@/components/ui/row-actions"

<RowActions onEdit={() => edit(row)} onDuplicate={() => duplicate(row)} onDelete={() => remove(row)} />

Props

PropTypeDefaultDescription
onEdit() => voidAdds Edit as the first item.
onDuplicate() => voidAdds Duplicate after Edit.
onDelete() => voidAdds Delete in the destructive tone after a separator.
extra{ label: string; icon?: ReactNode; onSelect: () => void; disabled?: boolean }[][]More items, placed after Duplicate and before the separator.
labelstring"Row actions"Accessible name of the trigger.
align"start" | "end""end"Which edge of the trigger the menu aligns to.

Source

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

import * as React from "react";
import { CopyIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

export type RowAction = {
  label: string;
  /** A lucide icon, size-4. */
  icon?: React.ReactNode;
  onSelect: () => void;
  disabled?: boolean;
};

type RowActionsProps = Omit<React.ComponentProps<typeof Button>, "children" | "size" | "variant" | "onSelect"> & {
  /** Adds Edit. */
  onEdit?: () => void;
  /** Adds Duplicate. */
  onDuplicate?: () => void;
  /** Adds Delete, in the destructive tone, after a separator. */
  onDelete?: () => void;
  /** More items, placed after Duplicate and before the separator. */
  extra?: RowAction[];
  /** Accessible name of the trigger. */
  label?: string;
  /** Which edge of the trigger the menu aligns to. */
  align?: "start" | "end";
};

const itemClass = "h-8 rounded-sm focus:bg-muted focus:text-foreground";

/**
 * The kebab menu for a table row. A ghost icon trigger opens Edit, Duplicate, any extras, then Delete.
 * Items appear only when their handler is passed.
 */
export function RowActions({
  onEdit,
  onDuplicate,
  onDelete,
  extra = [],
  label = "Row actions",
  align = "end",
  className,
  ...props
}: RowActionsProps) {
  const hasPrimary = Boolean(onEdit || onDuplicate || extra.length);
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          type="button"
          variant="ghost"
          size="icon-sm"
          aria-label={label}
          data-slot="row-actions"
          className={cn("rounded-full text-muted-foreground", className)}
          {...props}
        >
          <EllipsisIcon aria-hidden="true" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align={align} className="min-w-40 rounded-md border shadow-md ring-0">
        {onEdit ? (
          <DropdownMenuItem onSelect={onEdit} className={itemClass}>
            <PencilIcon className="text-muted-foreground" aria-hidden="true" />
            Edit
          </DropdownMenuItem>
        ) : null}
        {onDuplicate ? (
          <DropdownMenuItem onSelect={onDuplicate} className={itemClass}>
            <CopyIcon className="text-muted-foreground" aria-hidden="true" />
            Duplicate
          </DropdownMenuItem>
        ) : null}
        {extra.map((action) => (
          <DropdownMenuItem
            key={action.label}
            onSelect={action.onSelect}
            disabled={action.disabled}
            className={cn(itemClass, "[&>svg]:text-muted-foreground")}
          >
            {action.icon}
            {action.label}
          </DropdownMenuItem>
        ))}
        {onDelete ? (
          <>
            {hasPrimary ? <DropdownMenuSeparator /> : null}
            <DropdownMenuItem variant="destructive" onSelect={onDelete} className={itemClass}>
              <Trash2Icon aria-hidden="true" />
              Delete
            </DropdownMenuItem>
          </>
        ) : null}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

For AI agents

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

When to use

  • The last cell of a table or list row, for actions that do not deserve their own buttons
  • Cards and list items with the same two to five actions on every entry
  • When one of the actions is destructive and should sit apart from the rest

When not to use

  • For a single action; show a plain button instead
  • For bulk actions on selected rows; use selection-bar
  • For actions that need confirmation right away; open a confirm dialog from onDelete

Agent notes

  • It takes functions (onEdit, onDuplicate, onDelete, extra[].onSelect), so the component rendering it must be a client component
  • Items appear only when their handler is passed; with no onDelete there is no separator
  • Delete uses the dropdown menu's destructive item variant; keep it for actions that cannot be undone and confirm them from onDelete
  • The trigger is a ghost icon-sm pill named by label; put it in a narrow right-aligned cell with py-1 so rows stay 40px