Row Actions Column
How to design table row Actions — identity Open, icon chrome, hide vs disable, trailing Delete, danger-only / multi-danger, and overflow when a row has many ops.
This page tells you exactly how to design the trailing Actions column on a browse / work-queue table. Read it as a checklist: if you skip a rule, operators will mis-click, miss Delete, or stare at a gray icon they cannot understand.
Actions is capability chrome, not decoration.
If this seat × status × Access cannot perform any row operation, do not render the column.
Open / View / Detail belong on the identity link (Order #, Request id, …) — never as a lonely trailing eye.
When rows differ, hide inline ops that do not apply to that record; disable + reason when the user can obviously unlock the op, or when the op lives in a ⋮ menu and other rows of this seat can still do it. Do not declare trailing Delete if no row can delete.
When a row has many ops, keep at most two frequent icons inline and put the rest in a ⋮ overflow — never an icon wall.
All demos below use the kit Table (not a hand-rolled HTML table). Copy the same props on QueryList (view="table" or view="list").
Examples
Open On The Identity Link
Problem you must not ship: every row has an Actions column whose only job is Open / View. Operators scan Order #, then hunt for a blank-header eye far to the right.
What to do instead: make Order # (or your primary code) an f.link to the detail URL. Drop the Actions column entirely when Open is the only “action.”
Wrong
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Open-only Actions column — eye far from identity
Right
| ORD-1001 | Confirmed |
Identity link opens the row — no Actions column for Open alone
"use client";
import type { ReactNode } from "react";
import {
ArchiveIcon,
BanIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
PencilIcon,
RefreshCwIcon,
Share2Icon,
Trash2Icon,
} from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { f } from "@/components/f-ui/field-types/catalog";
import { Table } from "@/components/f-ui/table/table";
import { DesignCompare } from "@/demos/_design/design-compare";
interface OrderRow {
id: string;
orderNumber: string;
status: "confirmed" | "draft";
syncing?: boolean;
}
const SINGLE_ROW: OrderRow[] = [
{ id: "ord-1001", orderNumber: "ORD-1001", status: "confirmed" },
];
const MIXED_ROWS: OrderRow[] = [
{ id: "ord-1001", orderNumber: "ORD-1001", status: "confirmed" },
{ id: "ord-1002", orderNumber: "ORD-1002", status: "draft", syncing: true },
{ id: "ord-1003", orderNumber: "ORD-1003", status: "draft" },
];
interface DocRow {
id: string;
name: string;
status: "failed" | "indexed";
}
const DOC_ROWS: DocRow[] = [
{ id: "doc-1", name: "install-guide.pdf", status: "failed" },
{ id: "doc-2", name: "architecture.pdf", status: "indexed" },
];
const docSchema = defineDataListSchema<DocRow>({
name: f.text({ label: "Name" }),
status: f.enum({
render: "status",
label: "Status",
variants: {
failed: { label: "Failed", tone: "destructive" },
indexed: { label: "Indexed", tone: "success" },
},
}),
});
const STATUS_VARIANTS = {
confirmed: { label: "Confirmed", tone: "success" as const },
draft: { label: "Draft", tone: "neutral" as const },
};
const textIdentitySchema = defineDataListSchema<OrderRow>({
orderNumber: f.text({ label: "Order #" }),
status: f.enum({
render: "status",
label: "Status",
variants: STATUS_VARIANTS,
}),
});
const linkIdentitySchema = defineDataListSchema<OrderRow>({
orderNumber: f.link({
label: "Order #",
href: (row) => `#${row.id}`,
}),
status: f.enum({
render: "status",
label: "Status",
variants: STATUS_VARIANTS,
}),
});
const openOnlyActions: RowAction<OrderRow>[] = [
{
id: "open",
label: "Open",
icon: <EyeIcon className="size-3.5" />,
onClick: () => {},
},
];
const editDeleteActions: RowAction<OrderRow>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
onClick: () => {},
},
];
/** Wrong: gray Delete on every row, including confirmed where Delete never applies. */
const availabilityWrongActions: RowAction<OrderRow>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
disabled: (row) => row.status !== "draft",
onClick: () => {},
},
];
/** Wrong: gray Retry on Indexed to even the column; Delete declared trailing but never shown. */
const ghostTrailingWrongActions: RowAction<DocRow>[] = [
{
id: "download",
label: "Download",
icon: <DownloadIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "retry",
label: "Retry",
icon: <RefreshCwIcon className="size-3.5" />,
disabled: (row) => row.status !== "failed",
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
hidden: true,
onClick: () => {},
},
];
const downloadRetryActions: RowAction<DocRow>[] = [
{
id: "download",
label: "Download",
icon: <DownloadIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "retry",
label: "Retry",
icon: <RefreshCwIcon className="size-3.5" />,
hidden: (row) => row.status !== "failed",
onClick: () => {},
},
];
/** Right: hide Delete when not applicable; disable Edit only when teachable (syncing). */
const availabilityRightActions: RowAction<OrderRow>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
disabled: (row) => Boolean(row.syncing),
disabledReason: "Wait until sync finishes",
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
hidden: (row) => row.status !== "draft",
onClick: () => {},
},
];
/** Hairline only while trailing Delete is visible (drafts); confirmed has no pipe. */
const trailingMixedActions: RowAction<OrderRow>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
hidden: (row) => row.status !== "draft",
onClick: () => {},
},
];
/** Wrong: Delete sits in the primary cluster — no trailing hairline. */
const noTrailingSeparatorActions: RowAction<OrderRow>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
onClick: () => {},
},
];
/** Danger-only — sole Delete stays inline (do not bury in More alone). */
const dangerOnlyActions: RowAction<OrderRow>[] = [
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
onClick: () => {},
},
];
/**
* Wrong danger-only: force menu display so the only op is behind ⋮.
* (Kit default inline would keep Delete visible — this is the anti-pattern.)
*/
const dangerOnlyBuriedActions = dangerOnlyActions;
/** Multi-danger primaries — Delete + Void both inline when ≤ maxInline. */
const multiDangerInlineActions: RowAction<OrderRow>[] = [
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
onClick: () => {},
},
{
id: "void",
label: "Void",
icon: <BanIcon className="size-3.5" />,
variant: "destructive",
onClick: () => {},
},
];
/**
* Author lists Delete first — kit reorders safes before danger; overflow
* inserts a divider before the first destructive (open ⋮ to see).
*/
const safeThenDangerOverflowActions: RowAction<OrderRow>[] = [
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
onClick: () => {},
},
{
id: "void",
label: "Void",
icon: <BanIcon className="size-3.5" />,
variant: "destructive",
onClick: () => {},
},
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "duplicate",
label: "Duplicate",
icon: <CopyIcon className="size-3.5" />,
onClick: () => {},
},
];
/** Many ops — Edit + secondary cluster + trailing Delete. */
const manyOpsActions: RowAction<OrderRow>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: () => {},
},
{
id: "duplicate",
label: "Duplicate",
icon: <CopyIcon className="size-3.5" />,
group: "secondary",
onClick: () => {},
},
{
id: "download",
label: "Download",
icon: <DownloadIcon className="size-3.5" />,
group: "secondary",
onClick: () => {},
},
{
id: "share",
label: "Share",
icon: <Share2Icon className="size-3.5" />,
group: "secondary",
onClick: () => {},
},
{
id: "archive",
label: "Archive",
icon: <ArchiveIcon className="size-3.5" />,
group: "lifecycle",
onClick: () => {},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
onClick: () => {},
},
];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function DemoPanel({
children,
caption,
}: {
children: ReactNode;
caption: string;
}) {
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-md border bg-card">{children}</div>
<p className="text-xs text-muted-foreground">{caption}</p>
</div>
);
}
function OpenOnlyEyeTable() {
const dataList = useDataList({
schema: textIdentitySchema,
listCode: "design-row-actions-open-only",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={openOnlyActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function IdentityLinkTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-identity-link",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return <Table dataList={dataList} />;
}
function TextActionsTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-text",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={editDeleteActions}
rowActionsPresentation="text"
rowActionsDisplay="inline"
/>
);
}
function IconActionsTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-icon",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={editDeleteActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function AvailabilityWrongTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-availability-wrong",
data: MIXED_ROWS,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={availabilityWrongActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function AvailabilityRightTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-availability-right",
data: MIXED_ROWS,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={availabilityRightActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function GhostTrailingWrongTable() {
const dataList = useDataList({
schema: docSchema,
listCode: "design-row-actions-ghost-trailing-wrong",
data: DOC_ROWS,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={ghostTrailingWrongActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function DownloadRetryTable() {
const dataList = useDataList({
schema: docSchema,
listCode: "design-row-actions-download-retry-right",
data: DOC_ROWS,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={downloadRetryActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function TrailingWrongTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-trailing-wrong",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={noTrailingSeparatorActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function TrailingRightTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-trailing-right",
data: MIXED_ROWS,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={trailingMixedActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>
);
}
function ManyOpsWallTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-many-wall",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={manyOpsActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
maxInlineRowActions={6}
/>
);
}
function ManyOpsOverflowTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-many-overflow",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={manyOpsActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
maxInlineRowActions={2}
/>
);
}
function DangerOnlyBuriedTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-danger-only-buried",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={dangerOnlyBuriedActions}
rowActionsPresentation="icon"
rowActionsDisplay="menu"
/>
);
}
function DangerOnlyInlineTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-danger-only-inline",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={dangerOnlyActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
maxInlineRowActions={2}
/>
);
}
function MultiDangerInlineTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-multi-danger-inline",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={multiDangerInlineActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
maxInlineRowActions={2}
/>
);
}
function SafeThenDangerOverflowTable() {
const dataList = useDataList({
schema: linkIdentitySchema,
listCode: "design-row-actions-safe-then-danger",
data: SINGLE_ROW,
getRowId: (row) => row.id,
});
return (
<Table
dataList={dataList}
rowActions={safeThenDangerOverflowActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
maxInlineRowActions={1}
/>
);
}
export function RowActionsIdentityLinkDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Open-only Actions column — eye far from identity">
<OpenOnlyEyeTable />
</DemoPanel>
}
right={
<DemoPanel caption="Identity link opens the row — no Actions column for Open alone">
<IdentityLinkTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
export function RowActionsIconChromeDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Text links in the Actions column — wider trailing chrome">
<TextActionsTable />
</DemoPanel>
}
right={
<DemoPanel caption="Icon + tooltip; blank header (sr-only Actions); narrow width">
<IconActionsTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
export function RowActionsGhostTrailingDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Gray Retry on Indexed to even the column; Delete declared trailing but never shown">
<GhostTrailingWrongTable />
</DemoPanel>
}
right={
<DemoPanel caption="Download every row; hide Retry on Indexed; do not declare Delete">
<DownloadRetryTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
export function RowActionsAvailabilityDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Gray Delete on confirmed rows — silent disabled forest">
<AvailabilityWrongTable />
</DemoPanel>
}
right={
<DemoPanel caption="Hide Delete on confirmed (no leftover pipe); disable Edit only while syncing">
<AvailabilityRightTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
export function RowActionsTrailingSeparatorDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Delete mixed into the primary cluster — no group boundary">
<TrailingWrongTable />
</DemoPanel>
}
right={
<DemoPanel caption="Hairline only on rows where Delete is visible; confirmed has no pipe">
<TrailingRightTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
export function RowActionsManyOpsOverflowDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Icon wall — every op forced inline (crowded, hard to scan)">
<ManyOpsWallTable />
</DemoPanel>
}
right={
<DemoPanel caption="Keep Edit inline; rest in ⋮; Delete stays trailing after the hairline">
<ManyOpsOverflowTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
/** Danger-only: wrong buries sole Delete in ⋮; right keeps it inline. */
export function RowActionsDangerOnlyDemo() {
return (
<QueryClientProvider client={queryClient}>
<DesignCompare
wrong={
<DemoPanel caption="Wrong — sole Delete behind ⋮ (extra click, easy to miss)">
<DangerOnlyBuriedTable />
</DemoPanel>
}
right={
<DemoPanel caption="Right — danger-only stays inline when count ≤ maxInline">
<DangerOnlyInlineTable />
</DemoPanel>
}
/>
</QueryClientProvider>
);
}
/** Multi-danger inline + safe-then-danger overflow with menu divider. */
export function RowActionsDangerSurfaceDemo() {
return (
<QueryClientProvider client={queryClient}>
<div className="space-y-6">
<DemoPanel caption="Multi-danger — Delete + Void both inline (≤ maxInline); both marked destructive">
<MultiDangerInlineTable />
</DemoPanel>
<DemoPanel caption="Author listed Delete first — kit shows Edit inline; open ⋮ for Duplicate then divider then Delete / Void">
<SafeThenDangerOverflowTable />
</DemoPanel>
</div>
</QueryClientProvider>
);
}How to wire it
// Schema — identity Open (no Actions column)
orderNumber: f.link({
label: "Order #",
href: (row) => `/orders/${row.id}`,
})- Use the schema field above on your primary code column.
- Mount a host
LinkProvideronce in the app shell. - Do not pass
rowActionsfor Open alone.
Icon Chrome And Header
Problem: text links “Edit Delete” plus a visible “Actions” title make the trailing column wide and noisy.
What to do: use icon + Tooltip; keep the header visually blank (screen readers still hear “Actions”). Kit default on QueryList / Table is already rowActionsPresentation="icon".
Wrong
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Text links in the Actions column — wider trailing chrome
Right
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Icon + tooltip; blank header (sr-only Actions); narrow width
How to wire it
<Table
dataList={handle}
rowActions={rowActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline"
/>Pass Lucide icons on each action (size-3.5 / size-4). Every icon needs a label (Tooltip + accessible name). Business-specific verbs without a universal metaphor → use text, not a mystery glyph.
Hide Vs Disable Per Row
Problem: every row shows the same gray Delete. Confirmed orders can never be deleted, but the icon still sits there with no explanation. Operators click it, nothing happens, they think the app is broken.
What to do: pick by surface, then by why the op is unavailable:
| Surface | Why unavailable | What you set |
|---|---|---|
| Inline / trailing icon | This seat never has the capability | Do not declare (or hidden for every row) |
| Inline / trailing icon | This record cannot do it and cannot unlock it | hidden |
| Inline / trailing icon | Temporary, and it is obvious how to enable (syncing, a lock the user can clear) | disabled + disabledReason |
⋮ / rowActionsDisplay: "menu" | This row cannot, but other rows of this seat can | disabled + disabledReason (keep the menu item) |
| Toolbar / batch | Waiting on a valid selection | Prefer disable |
| Any | Unlock is elsewhere / not obvious | Keep enabled; explain on activate — or move to Detail |
Left demo = wrong gray forest. Right demo = hide Delete on confirmed with no leftover hairline; disable Edit only on the syncing draft with a reason.
Wrong
How to wire it
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
hidden: (row) => row.status !== "draft",
onClick: (row) => deleteDraft(row.id),
},
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
disabled: (row) => row.syncing,
disabledReason: "Wait until sync finishes",
onClick: (row) => openEdit(row.id),
},Never ship disabled without disabledReason. Do not gray inline icons to make the column look even. Toolbar / batch bars that wait for a valid selection are the place for “disable until ready” — do not confuse that with inline row buttons.
Ghost Trailing (empty vertical line)
Problem: every row can Download, only Failed can Retry, and no row can Delete — but Retry is still grayed on Indexed “so the column looks even,” and Delete is still declared placement: "trailing" and hidden. Operators see a useless Retry. Authors ship a Delete that never appears.
What to do: omit Delete. Hide Retry on Indexed. Remaining icons end-align. Status already explains why Retry is missing. Do not declare trailing just to keep a hairline.
Wrong
Actions | ||
|---|---|---|
| install-guide.pdf | Failed | |
| architecture.pdf | Indexed |
Gray Retry on Indexed to even the column; Delete declared trailing but never shown
Right
Actions | ||
|---|---|---|
| install-guide.pdf | Failed | |
| architecture.pdf | Indexed |
Download every row; hide Retry on Indexed; do not declare Delete
How to wire it
const rowActions = [
{
id: "download",
label: "Download",
icon: <DownloadIcon className="size-3.5" />,
onClick: (row) => download(row.id),
},
{
id: "retry",
label: "Retry",
icon: <RefreshCwIcon className="size-3.5" />,
hidden: (row) => row.status !== "failed",
onClick: (row) => retry(row.id),
},
];Trailing Separator (the small vertical line)
What that hairline is: a group boundary, not decoration. It separates the primary cluster (Edit, Duplicate, …) from an always-visible trailing op (usually Delete).
What to do: use placement: "trailing" when Delete must stay visible beside many safes (at most one trailing). The kit draws border-l before that cluster. Trailing is not mandatory for every destructive — the kit already orders safes before danger in the primary cluster / overflow, and keeps danger-only rows inline when count ≤ maxInline. Do not leave an empty hairline when nothing is trailing on that row. If Delete is hidden for a confirmed order, that row has no pipe. Use placement: "trailing" only when at least some rows actually show Delete.
Wrong
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Delete mixed into the primary cluster — no group boundary
How to wire it
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing", // ← this creates the hairline group
hidden: (row) => row.status !== "draft", // no pipe on confirmed
confirm: { /* … */ },
onClick: (row) => remove(row.id),
}Editable Table’s built-in Remove already uses trailing destructive — copy that pattern.
Many Ops Per Row (overflow — do not build an icon wall)
Problem you must not ship: Edit, Duplicate, Download, Share, Archive, Delete all as separate icons on every row. The Actions column becomes a Christmas tree. Operators cannot tell which glyph matters. Horizontal space dies.
What to do (feed this into the product conversation):
- List every single-row op for this seat.
- Pick at most two that are frequent and universal (usually Edit; sometimes Duplicate). Those stay inline icons.
- Put the rest in a persistent
⋮overflow menu (icon + label inside the menu). - Prefer Delete as
placement: "trailing"after the hairline when many safes compete for inline space — trailing does not count toward the primarymaxInlinebudget. If Delete is the only (or one of few) primaries, keep it inline withvariant: "destructive"instead of forcing trailing. - If an op is rare or needs a whole workflow (approve wizard, audit history) → put it on the Detail / Object Page, not in the row.
- If the same op applies to many rows at once → selection + batch / toolbar, not the same button fifty times. Fiori xor: do not ship both toolbar/batch Delete and row Delete for the same destroy semantics.
- If the op only affects one field → put the control in that column, not in Actions.
Left = forced icon wall (raise maxInlineRowActions to fit everything — do not). Right = kit default density (maxInlineRowActions = 2): Edit inline, secondary ops in ⋮, Delete trailing when it must stay visible.
Wrong
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Icon wall — every op forced inline (crowded, hard to scan)
Right
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Keep Edit inline; rest in ⋮; Delete stays trailing after the hairline
How to wire it
const rowActions = [
{ id: "edit", label: "Edit", icon: <PencilIcon className="size-3.5" />, onClick: … },
{ id: "duplicate", label: "Duplicate", icon: <CopyIcon className="size-3.5" />, group: "secondary", onClick: … },
{ id: "download", label: "Download", icon: <DownloadIcon className="size-3.5" />, group: "secondary", onClick: … },
{ id: "share", label: "Share", icon: <Share2Icon className="size-3.5" />, group: "secondary", onClick: … },
{ id: "archive", label: "Archive", icon: <ArchiveIcon className="size-3.5" />, group: "lifecycle", onClick: … },
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
onClick: …,
},
];
<Table
dataList={handle}
rowActions={rowActions}
rowActionsPresentation="icon"
rowActionsDisplay="inline" // primary cluster + ⋮ when over the cap
maxInlineRowActions={2} // icon default — do not raise this to “fit everything”
/>When nothing deserves an always-visible icon: set rowActionsDisplay="menu" so the cell is only ⋮ (plus trailing Delete if you still need it).
group: same string → menu separator between groups. Use it so a long overflow is scannable (secondary vs lifecycle vs danger).
Danger Surface (danger-only and multi-danger)
Problem you must not ship: the only row op is Delete, but it lives exclusively behind ⋮ — or you invent a second trailing hairline for Void + Delete.
What to do:
- Danger-only (every visible primary is destructive): keep them inline when count ≤
maxInline. Do not forcerowActionsDisplay="menu"just to “hide” Delete. - Multi-danger (Delete + Void, …): allowed — both use
variant: "destructive". If ≤maxInline, both stay inline. - Safe + danger: kit reorders safes before danger (even if you declare Delete first). Overflow inserts a divider before the first destructive — open
⋮in the second panel below. - At most one
placement: "trailing"; extra trailing defs are ignored. Prefer trailing when Delete must stay visible beside many safes, not for every destructive. - Fiori xor: toolbar/batch Delete or row Delete for the same destroy — not both (no second demo — product policy on the same table).
Wrong
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Wrong — sole Delete behind ⋮ (extra click, easy to miss)
Right
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Right — danger-only stays inline when count ≤ maxInline
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Multi-danger — Delete + Void both inline (≤ maxInline); both marked destructive
Actions | ||
|---|---|---|
| ORD-1001 | Confirmed |
Author listed Delete first — kit shows Edit inline; open ⋮ for Duplicate then divider then Delete / Void
How to wire it
// Danger-only — inline Delete (do not use display: "menu" for a sole destroy)
const dangerOnly = [
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
confirm: { description: "Permanently deletes this order." },
onClick: (row) => remove(row.id),
},
] satisfies RowAction<Order>[];
// Multi-danger + safes — author order may put Delete first; kit still shows safes first
const mixed = [
{ id: "delete", label: "Delete", variant: "destructive", icon: <Trash2Icon className="size-3.5" />, onClick: … },
{ id: "void", label: "Void", variant: "destructive", icon: <BanIcon className="size-3.5" />, onClick: … },
{ id: "edit", label: "Edit", icon: <PencilIcon className="size-3.5" />, onClick: … },
{ id: "duplicate", label: "Duplicate", icon: <CopyIcon className="size-3.5" />, onClick: … },
] satisfies RowAction<Order>[];When To Use
- Work-queue / browse tables with Edit, Delete, Approve, Duplicate, Download, …
- Embedded Editable Table when duplicate / remove / custom row ops exist.
Do not use Actions to look “complete” on read-only audit views, or to host Open alone.
Hard Rules (R-ACT)
| ID | Rule — read as “you must” |
|---|---|
| R-ACT1 | Build Actions only when ≥1 real op can show for this seat × status × Access. |
| R-ACT2 | If every candidate is hidden for the current result → omit the column. |
| R-ACT3 | Never ship a mode whose Actions column is always empty. |
| R-ACT4 | Open / View / Detail alone → identity f.link (or row navigate). No eye-only Actions. |
| R-ACT5 | With Edit/Delete present, Open still prefers the identity link; do not duplicate Open as an eye. |
| R-ACT6 | Default presentation = icon. |
| R-ACT7 | Icons only for universal metaphors; always Tooltip + accessible name. |
| R-ACT8 | Ambiguous / business-specific verbs → text. |
| R-ACT9 | One control is icon or text, never icon+label. |
| R-ACT10 | Do not ship a forest of silent disabled icons for record-state N/A. |
| R-ACT11 | Visible header empty; sr-only “Actions”. |
| R-ACT12 | Width from control geometry — not from a visible “Actions” title. |
| R-ACT13 | Do not force a Kibana-style visible “Actions” title as v1 default. |
| R-ACT14 | At most one placement: "trailing" (hairline) when Delete must stay visible beside many safes. Not mandatory for every destructive — kit orders safes before danger; danger-only stays inline ≤ maxInline. No empty hairline when trailing is missing or hidden for this row. |
Availability (R-ACT-A) — hide vs disable
| ID | Rule |
|---|---|
| R-ACT-A1 | Seat never has capability → do not declare (or hidden for every row). |
| R-ACT-A2 | Inline / trailing icon: record cannot do it and cannot unlock it → hidden. |
| R-ACT-A3 | Temporary + obvious how to enable → disabled + required disabledReason. |
| R-ACT-A4 | Toolbar / batch waiting on selection → prefer disable. Hide the toolbar control only if this view can never contain an eligible row. |
| R-ACT-A5 | hidden collapses — no placeholder, no hairline when trailing is hidden on this row. Remaining controls end-align. |
| R-ACT-A6 | Overflow / menu: this row N/A but other rows of this seat can → disabled + reason, not hidden. |
| R-ACT-A7 | Unlock not obvious → keep enabled, explain on activate, or move to Detail. |
Seat ever has this op in this view?
NO → do not declare
YES ↓
Toolbar / batch?
YES → disable until selection valid (or hide if the view can never qualify)
NO ↓
Overflow / kebab menu?
YES → this row N/A → disabled + reason (omit only if the seat never can)
NO ↓ (inline / trailing icon)
Temporary + obvious unlock?
YES → disabled + disabledReason
NO → hidden for this row (collapse; no empty slot)Many Ops (R-ACT-M) — overflow and where extras go
| ID | Rule |
|---|---|
| R-ACT-M1 | Cap inline primary icons at 2 (default). Do not raise maxInlineRowActions to fit an icon wall. |
| R-ACT-M2 | Ops beyond the cap go in a persistent ⋮ overflow (rowActionsDisplay: "inline"). |
| R-ACT-M3 | If no op deserves an always-visible icon → rowActionsDisplay: "menu". |
| R-ACT-M4 | Overflow items = icon + label; kit inserts a divider before the first destructive when safes and danger share the menu; group separators still apply among safes. |
| R-ACT-M5 | Rare or workflow-deep ops → Detail / Object Page, not endless row menus. |
| R-ACT-M6 | Multi-row ops → selection + batch / toolbar. Xor: toolbar/batch Delete or row Delete for the same destroy — not both. |
| R-ACT-M7 | Field-scoped ops stay in that column; Actions is for row-as-a-whole only. |
How many single-row ops does this seat need?
Navigate only → identity link; no Actions
1–2 frequent (+ Delete) → inline icons; Delete inline danger or trailing
3–5 frequent → top 1–2 inline; rest in ⋮; prefer trailing Delete if it must stay visible
Danger-only (Delete / Void …) → keep inline ≤ maxInline; do not bury sole Delete in More
Rare / deep workflow → Detail page
Same destroy on many rows → batch toolbar **xor** row Delete (not both)
Tied to one field → that columnDecision Tree (Column Presence)
Can this view show a non-navigate row op for this viewer?
├─ No, only Open → identity link; no Actions
├─ No ops → do not pass rowActions
└─ Yes
├─ Attach Actions (icon-first; blank header)
├─ Per-row availability → R-ACT-A
├─ Many ops → R-ACT-M (overflow / Detail / batch)
├─ Delete → variant destructive; trailing only when it must stay visible (R-ACT14)
├─ Batch Delete present? → omit row Delete for same destroy (xor)
└─ Entire result empty of ops → omit column (R-ACT2)Integration Notes (f-ui) — copy this into your page
QueryList / Table
| Step | Do this |
|---|---|
| 1 | Mount LinkProvider in the app shell. |
| 2 | Put Open on f.link identity field. |
| 3 | Build rowActions only for real ops; omit the prop when the list is empty for this mode. |
| 4 | Prefer rowActionsPresentation="icon" (QueryList / Table default). |
| 5 | Delete → variant: "destructive" (+ confirm). Prefer placement: "trailing" when Delete must stay visible beside many safes — not required for every destructive. |
| 6 | Per-row N/A → inline hidden; overflow N/A → disabled + disabledReason; teachable block → disabled + disabledReason. |
| 7 | Many ops → keep maxInlineRowActions at 2; let ⋮ take the rest; kit orders safes before danger + danger divider in overflow. |
| 8 | Do not raise maxInlineRowActions to fit every op. That is the icon-wall anti-pattern. |
| 9 | Toolbar/batch Delete xor row Delete for the same destroy semantics. |
Editable Table
Reserve trailing Actions only when duplicate / remove / custom ops are enabled. Pass undefined when the seat has zero capabilities — an empty array still creates the column, so omit the prop instead. Built-in Remove is already trailing destructive.
Anti-Patterns
| If you see this | Fix it with |
|---|---|
| Eye-only Open column | Identity f.link (R-ACT4) |
| Visible “Actions” title sizing the column | Blank + sr-only (R-ACT11–12) |
| Empty Actions on audit Detail | Omit rowActions (R-ACT3) |
| Actions always on “for consistency” across seats | Seat-conditional column (R-ACT1) |
| Mystery business icons | Text (R-ACT8) |
| Gray Delete on every row when only drafts delete | hidden (R-ACT-A2) |
| Disabled with no reason | disabledReason (R-ACT-A3) |
| Empty ` | ` after Download / Retry |
| Gray Retry on Indexed “for alignment” | hidden (R-ACT-A2) |
Retry missing from ⋮ on Indexed while Failed still has it | disabled + reason (R-ACT-A6) |
| Sole Delete buried only in More | Keep danger-only inline ≤ maxInline (R-ACT14) |
| Delete needs to stay visible beside many safes | placement: "trailing" (R-ACT14) |
| Six inline icons on every row | Keep maxInlineRowActions at 2 + ⋮ (R-ACT-M1–M2) |
| Rare wizard buried as a row icon | Detail page (R-ACT-M5) |
| Same Delete on 50 rows and row Delete | Batch xor row Delete (R-ACT-M6) |
Industry Backing
- Carbon Data table — <3 inline icons; else overflow; persistent
⋮ - SAP HCM Action column — ≤3 in column; extras → toolbar / Object Page; hide N/A
- SAP Fiori — Action Placement — row icons RHS; custom: one most important
- SAPUI5 applicable-path — line-item hide; table header btn disable
- Oracle BLAF — prefer link on primary object name
- Jakob Nielsen — Inactive controls — disable + explain vs hide-never
- Smashing — Hidden vs Disabled — “Will this user ever…?”
- NN/g — Data tables — crowding / discoverability when too many row ops
Related
- CRUD Page Patterns — Action Placement
- Button And Action Emphasis
- Table — row actions API
- Editable Table — embedded trailing actions
- Query List — list recipe defaults