Appearance
Multi Select
When to Use
- ANY list page where rows can be acted on in bulk (archive, assign, export, approve, delete)
- The list is a
DataTable— selection and the bulk-action bar are built in; you only declare the actions - Some actions apply to only part of a selection (approve only pending rows, delete only archived ones)
Layout
Pass rowSelection.actions to useDataTable. The moment one row is selected, DataTable.Footer becomes the bulk-action bar: a tinted strip with the selection count, the actions, and Clear, with pagination kept on the right. It turns back into the normal footer when the selection empties. In <Layout fill> the footer is already pinned; on a page that scrolls, the bar sticks to the bottom of the viewport until the end of the table is reached.
+--------------------------------------------------------------------+
| Layout.Header title [Create] |
+--------------------------------------------------------------------+
| Layout.Column |
| DataTable.Root |
| Toolbar [Filters] [Columns] |
| [x] | Col | Col | Col | Col |
| [x] | row | row | row | row |
| [ ] | row | row | row | row |
| [x] | row | row | row | row |
| DataTable.Footer, while rows are selected: |
| 3 of 240 selected | [Confirm] [Export] [⋯] | Clear ‹ 1/10 › |
+--------------------------------------------------------------------+Page Implementation
tsx
function InteractionMultiSelect() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [pendingCancel, setPendingCancel] = useState<PendingCancel | null>(null);
const [cancelling, setCancelling] = useState(false);
const [cancelError, setCancelError] = useState<string | null>(null);
const removeCompletedIds = (ids: string[]) => {
const completed = new Set(ids);
setSelectedIds((current) => current.filter((id) => !completed.has(id)));
};
const actions: SelectionAction[] = [
{
id: "confirm",
label: "Confirm",
// The operation returns actual successes; never clear ineligible or newer selections.
onClick: async (ids) => removeCompletedIds(await confirmOrders(ids)),
},
{
id: "export",
label: "Export",
onClick: (ids) => exportOrders(ids),
},
{
id: "assign",
label: "Assign owner",
onClick: async (ids) => window.alert(`Assigning ${ids.length} order(s)`),
},
{
// 4th action: lands in the More actions menu.
id: "cancel",
label: "Cancel",
variant: "destructive",
onClick: (ids, { run }) => {
setCancelError(null);
setPendingCancel({ ids: [...ids], run });
},
},
];
// In a real app, fetch eligibility for these snapshot ids before showing this explanation.
const cancellableCount = mockOrders.filter(
(order) => pendingCancel?.ids.includes(order.id) && order.status !== "Shipped",
).length;
const table = useDataTable({
columns,
data: { rows: mockOrders, total: mockOrders.length },
rowSelection: { selectedIds, onChange: setSelectedIds, actions },
});
return (
<>
<DataTable.Root value={table}>
<DataTable.Table />
<DataTable.Footer>
<DataTable.Pagination />
</DataTable.Footer>
</DataTable.Root>
<Dialog.Root
open={pendingCancel !== null}
onOpenChange={(open) => {
if (!open && !cancelling) setPendingCancel(null);
}}
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Cancel selected orders?</Dialog.Title>
<Dialog.Description>
{cancellableCount} of {pendingCancel?.ids.length ?? 0} selected orders can be
cancelled; shipped orders are excluded. Cancelled orders can't be reopened.
</Dialog.Description>
</Dialog.Header>
{cancelError && <p role="alert">{cancelError}</p>}
<Dialog.Footer>
<Dialog.Close render={<Button variant="outline" disabled={cancelling} />}>
Keep orders
</Dialog.Close>
<Button
variant="destructive"
disabled={cancelling || cancellableCount === 0}
onClick={async () => {
if (!pendingCancel || cancelling) return;
setCancelling(true);
setCancelError(null);
try {
const cancelledIds = await pendingCancel.run(cancelOrders(pendingCancel.ids));
removeCompletedIds(cancelledIds);
setPendingCancel(null);
} catch {
setCancelError("Cancellation failed. Please try again.");
} finally {
setCancelling(false);
}
}}
>
{cancelling ? "Cancelling…" : "Cancel orders"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</>
);
}Constraints
- Declare bulk actions as the non-generic
SelectionAction[]inuseDataTable'srowSelection.actions— do NOT hand-build the bar, a floating panel, or checkboxes; the footer renders the selection count, actions, overflow menu, and Clear. ProvidingrowSelectionenables checkboxes even whenactionsis omitted or empty; the bar requires non-empty actions and selected ids - Keep
DataTable.Footerin the tree — it is where the bar renders - Selection stores IDs only, not row objects.
onClickreceives a click-time copy of all selected ids (string[]), including unloaded ids; fetch any records required for the operation in the consumer's ID-based workflow - The consumer owns eligibility checks, required fetching, confirmation UI, and execution; backend validation remains authoritative. Selection actions have no row-based
canApply, automatic eligible count, or eligibility-based disabled state - Return the promise from
onClickfor async work: the bar disables its actions and shows a spinner while it runs. Success and rejection both leave selection unchanged; there is nokeepSelectionoption. Rejections are logged; show errors in the consumer UI - Use controlled
rowSelection: { selectedIds, onChange }to remove only ids actually changed, with a functional update against the current selection. Preserve ineligible ids and ids selected after the operation started; cancellation or failure does not clear selection - At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last
- Destructive bulk actions MUST open an
interaction/confirmdialog fromonClick. Optionally keep therunhelper and pass it the later request (await run(deleteRows(ids))) for pending tracking only.clearSelectionexplicitly clears every page when that is intentional - Share only presentation fields (
id,label,icon,variant) as a non-genericDataTableAction.RowAction<TRow>retainscanApplyfor its concrete row; bulk eligibility belongs in the ID-based consumer workflow - Selection persists across pagination, filter, and sort changes — do NOT clear it on those
Anti-patterns
- Hand-rolled selection (
useState<Set>, raw<input type="checkbox">,Table.Root) when the list is a DataTable - A floating or fixed-position action bar over the table instead of the footer bar
- Placing bulk-action buttons in the page header or the table toolbar
- Treating the currently loaded rows as the full selection or deriving bulk eligibility from incomplete row data
- Clearing the whole selection after partial success, or overwriting it with the click-time snapshot and losing newer selections
- Fire-and-forget async
onClickthat doesn't return its promise or userun— users can fire it twice - Firing destructive bulk actions without an interaction/confirm step
- Per-row
Menuactions as a substitute for bulk actions when selection > 0