Multi Select
When to Use
- ANY list page where rows can be acted on in bulk (archive, assign, export, approve, delete)
- Selection is initiated by clicking a leading-column checkbox on rows
- Selection state needs to persist across pagination and filter changes
Layout
Floating action bar appears the moment selection count goes from 0 → 1, anchored to the bottom of the viewport, centered horizontally, with elevation. It disappears when selection returns to 0.
+---------------------------------------------------------+
| Layout.Header title [Filter] [Create] |
+---------------------------------------------------------+
| Layout.Column |
| Table.Root |
| [x] | Col | Col | Col | Col |
| [x] | row | row | row | row |
| [ ] | row | row | row | row |
| [x] | row | row | row | row |
| |
| +--------------------------------------+ |
| | 3 selected [Archive] [Export] [⋯] [Clear] | |
| +--------------------------------------+ |
+---------------------------------------------------------+Page Implementation
tsx
function InteractionMultiSelect() {
const orders = mockOrders;
const onArchive = (ids: string[]) => window.alert(`Archiving ${ids.length}`);
const onExport = (ids: string[]) => window.alert(`Exporting ${ids.length}`);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleRow = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
if (selectedIds.size === orders.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(orders.map((o) => o.id)));
}
};
const clearSelection = () => setSelectedIds(new Set());
const selectedCount = selectedIds.size;
return (
<>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head className="w-10">
<input
type="checkbox"
checked={selectedIds.size === orders.length && orders.length > 0}
onChange={toggleAll}
aria-label="Select all on page"
/>
</Table.Head>
<Table.Head>Order #</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{orders.map((order) => (
<Table.Row key={order.id}>
<Table.Cell>
<input
type="checkbox"
checked={selectedIds.has(order.id)}
onChange={() => toggleRow(order.id)}
aria-label={`Select ${order.number}`}
/>
</Table.Cell>
<Table.Cell>{order.number}</Table.Cell>
<Table.Cell>{order.status}</Table.Cell>
<Table.Cell>${order.total.toLocaleString()}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
{selectedCount > 0 && (
<div
role="toolbar"
aria-label="Bulk actions"
className="fixed bottom-4 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2 rounded-md border bg-popover px-3 py-2 shadow-lg"
>
<span className="text-sm font-medium">{selectedCount} selected</span>
<Button size="sm" onClick={() => onArchive([...selectedIds])}>
Archive
</Button>
<Button size="sm" variant="outline" onClick={() => onExport([...selectedIds])}>
Export
</Button>
<Menu.Root>
<Menu.Trigger>
<Button size="sm" variant="outline" aria-label="More actions">
⋯
</Button>
</Menu.Trigger>
<Menu.Content>
<Menu.Item>Assign owner</Menu.Item>
<Menu.Item>Tag</Menu.Item>
<Menu.Separator />
<Menu.Item>Delete</Menu.Item>
</Menu.Content>
</Menu.Root>
<Button size="sm" variant="ghost" onClick={clearSelection}>
Clear
</Button>
</div>
)}
</>
);
}Constraints
- Count label + Clear button are always present in the bar
- Max 3 inline action buttons — 4th onward collapse behind an overflow
Menu - Destructive bulk actions MUST open an
interaction/confirmdialog - Filter or sort change must NOT silently clear the selection
- Pagination MUST preserve selection across pages
Anti-patterns
- Placing bulk-action buttons in the page header — bulk actions belong only in the floating bar
- Hiding the bar behind row hover or right-click — bar must be visible when selection > 0
- Omitting the count or the Clear affordance — both are mandatory
- Letting filter/sort changes silently drop selection
- Firing destructive bulk actions without an interaction/confirm step
- Per-row
Menuactions as a substitute for bulk actions when selection > 0