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
| Page | Updated | Actions |
|---|---|---|
| Home | 2 hours ago | |
| Pricing | Yesterday | |
| Changelog | 3 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
| Prop | Type | Default | Description |
|---|---|---|---|
| onEdit | () => void | Adds Edit as the first item. | |
| onDuplicate | () => void | Adds Duplicate after Edit. | |
| onDelete | () => void | Adds 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. |
| label | string | "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