f-ui
Design

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

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-1001Confirmed

Open-only Actions column — eye far from identity

Right

ORD-1001Confirmed

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}`,
})
  1. Use the schema field above on your primary code column.
  2. Mount a host LinkProvider once in the app shell.
  3. Do not pass rowActions for 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-1001Confirmed

Text links in the Actions column — wider trailing chrome

Right

Actions
ORD-1001Confirmed

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:

SurfaceWhy unavailableWhat you set
Inline / trailing iconThis seat never has the capabilityDo not declare (or hidden for every row)
Inline / trailing iconThis record cannot do it and cannot unlock ithidden
Inline / trailing iconTemporary, 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 candisabled + disabledReason (keep the menu item)
Toolbar / batchWaiting on a valid selectionPrefer disable
AnyUnlock is elsewhere / not obviousKeep 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

Actions
ORD-1001Confirmed
ORD-1002Draft
ORD-1003Draft

Gray Delete on confirmed rows — silent disabled forest

Right

Actions
ORD-1001Confirmed
ORD-1002Draft
Wait until sync finishes
ORD-1003Draft

Hide Delete on confirmed (no leftover pipe); disable Edit only while syncing

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.pdfFailed
architecture.pdfIndexed

Gray Retry on Indexed to even the column; Delete declared trailing but never shown

Right

Actions
install-guide.pdfFailed
architecture.pdfIndexed

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-1001Confirmed

Delete mixed into the primary cluster — no group boundary

Right

Actions
ORD-1001Confirmed
ORD-1002Draft
ORD-1003Draft

Hairline only on rows where Delete is visible; confirmed has no pipe

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):

  1. List every single-row op for this seat.
  2. Pick at most two that are frequent and universal (usually Edit; sometimes Duplicate). Those stay inline icons.
  3. Put the rest in a persistent overflow menu (icon + label inside the menu).
  4. Prefer Delete as placement: "trailing" after the hairline when many safes compete for inline space — trailing does not count toward the primary maxInline budget. If Delete is the only (or one of few) primaries, keep it inline with variant: "destructive" instead of forcing trailing.
  5. 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.
  6. 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.
  7. 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-1001Confirmed

Icon wall — every op forced inline (crowded, hard to scan)

Right

Actions
ORD-1001Confirmed

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:

  1. Danger-only (every visible primary is destructive): keep them inline when count ≤ maxInline. Do not force rowActionsDisplay="menu" just to “hide” Delete.
  2. Multi-danger (Delete + Void, …): allowed — both use variant: "destructive". If ≤ maxInline, both stay inline.
  3. 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.
  4. At most one placement: "trailing"; extra trailing defs are ignored. Prefer trailing when Delete must stay visible beside many safes, not for every destructive.
  5. 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-1001Confirmed

Wrong — sole Delete behind ⋮ (extra click, easy to miss)

Right

Actions
ORD-1001Confirmed

Right — danger-only stays inline when count ≤ maxInline

Actions
ORD-1001Confirmed

Multi-danger — Delete + Void both inline (≤ maxInline); both marked destructive

Actions
ORD-1001Confirmed

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)

IDRule — read as “you must”
R-ACT1Build Actions only when ≥1 real op can show for this seat × status × Access.
R-ACT2If every candidate is hidden for the current result → omit the column.
R-ACT3Never ship a mode whose Actions column is always empty.
R-ACT4Open / View / Detail alone → identity f.link (or row navigate). No eye-only Actions.
R-ACT5With Edit/Delete present, Open still prefers the identity link; do not duplicate Open as an eye.
R-ACT6Default presentation = icon.
R-ACT7Icons only for universal metaphors; always Tooltip + accessible name.
R-ACT8Ambiguous / business-specific verbs → text.
R-ACT9One control is icon or text, never icon+label.
R-ACT10Do not ship a forest of silent disabled icons for record-state N/A.
R-ACT11Visible header empty; sr-only “Actions”.
R-ACT12Width from control geometry — not from a visible “Actions” title.
R-ACT13Do not force a Kibana-style visible “Actions” title as v1 default.
R-ACT14At 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

IDRule
R-ACT-A1Seat never has capability → do not declare (or hidden for every row).
R-ACT-A2Inline / trailing icon: record cannot do it and cannot unlock it → hidden.
R-ACT-A3Temporary + obvious how to enable → disabled + required disabledReason.
R-ACT-A4Toolbar / batch waiting on selection → prefer disable. Hide the toolbar control only if this view can never contain an eligible row.
R-ACT-A5hidden collapses — no placeholder, no hairline when trailing is hidden on this row. Remaining controls end-align.
R-ACT-A6Overflow / menu: this row N/A but other rows of this seat can → disabled + reason, not hidden.
R-ACT-A7Unlock 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

IDRule
R-ACT-M1Cap inline primary icons at 2 (default). Do not raise maxInlineRowActions to fit an icon wall.
R-ACT-M2Ops beyond the cap go in a persistent overflow (rowActionsDisplay: "inline").
R-ACT-M3If no op deserves an always-visible icon → rowActionsDisplay: "menu".
R-ACT-M4Overflow 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-M5Rare or workflow-deep ops → Detail / Object Page, not endless row menus.
R-ACT-M6Multi-row ops → selection + batch / toolbar. Xor: toolbar/batch Delete or row Delete for the same destroy — not both.
R-ACT-M7Field-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 column

Decision 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

StepDo this
1Mount LinkProvider in the app shell.
2Put Open on f.link identity field.
3Build rowActions only for real ops; omit the prop when the list is empty for this mode.
4Prefer rowActionsPresentation="icon" (QueryList / Table default).
5Delete → variant: "destructive" (+ confirm). Prefer placement: "trailing" when Delete must stay visible beside many safes — not required for every destructive.
6Per-row N/A → inline hidden; overflow N/A → disabled + disabledReason; teachable block → disabled + disabledReason.
7Many ops → keep maxInlineRowActions at 2; let take the rest; kit orders safes before danger + danger divider in overflow.
8Do not raise maxInlineRowActions to fit every op. That is the icon-wall anti-pattern.
9Toolbar/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 thisFix it with
Eye-only Open columnIdentity f.link (R-ACT4)
Visible “Actions” title sizing the columnBlank + sr-only (R-ACT11–12)
Empty Actions on audit DetailOmit rowActions (R-ACT3)
Actions always on “for consistency” across seatsSeat-conditional column (R-ACT1)
Mystery business iconsText (R-ACT8)
Gray Delete on every row when only drafts deletehidden (R-ACT-A2)
Disabled with no reasondisabledReason (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 itdisabled + reason (R-ACT-A6)
Sole Delete buried only in MoreKeep danger-only inline ≤ maxInline (R-ACT14)
Delete needs to stay visible beside many safesplacement: "trailing" (R-ACT14)
Six inline icons on every rowKeep maxInlineRowActions at 2 + (R-ACT-M1–M2)
Rare wizard buried as a row iconDetail page (R-ACT-M5)
Same Delete on 50 rows and row DeleteBatch xor row Delete (R-ACT-M6)

Industry Backing

On this page