Multi-select & Bulk Actions
Select multiple rows with checkboxes (including shift-click range selection) then apply a bulk action via the contextual toolbar that appears above the list. The toolbar is only present when items are selected — keeping the default state clean.
Preview
| Name | Type | Size | Modified | |
|---|---|---|---|---|
| design-tokens.json | JSON | 48 KB | Today | |
| button.stories.tsx | TSX | 12 KB | Today | |
| color-palette.fig | Figma | 2.4 MB | Yesterday | |
| motion-spec.md | MD | 8 KB | May 6 | |
| component-index.csv | CSV | 3 KB | May 5 | |
| accessibility-audit.pdf | 1.1 MB | May 4 |
Selection states
| State | Header checkbox | Row visual | Bulk bar |
|---|---|---|---|
| None selected | Unchecked | Default background | Hidden |
| Some selected | Indeterminate | Accent tint on selected rows | Visible with count |
| All selected | Checked | All rows accent tinted | Visible — 'N selected' |
Keyboard & interactions
| Input | Action |
|---|---|
| Space on checkbox | Toggle selection for that row |
| Shift+Click | Select range from last-selected to clicked row |
| Cmd/Ctrl+A | Select all rows |
| Escape | Deselect all |
| Delete / Backspace | Delete selected (when bulk bar is focused) |
Implementation
MultiSelectTable.tsx
tsx
"use client";
import { useState, useCallback } from "react";
function MultiSelectTable({ rows }: { rows: Row[] }) {
const [selected, setSelected] = useState<Set<number>>(new Set());
const toggleAll = useCallback(() => {
setSelected((s) =>
s.size === rows.length
? new Set()
: new Set(rows.map((r) => r.id))
);
}, [rows]);
const toggle = useCallback((id: number) => {
setSelected((s) => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const allChecked = selected.size === rows.length;
const someChecked = selected.size > 0 && !allChecked;
return (
<div>
{/* Bulk action bar — only mounts when items are selected */}
{selected.size > 0 && (
<div
role="toolbar"
aria-label="Bulk actions"
className="flex items-center gap-3 p-3 mb-3 rounded-xl
bg-[rgb(var(--accent-subtle))] border border-[rgb(var(--accent-muted))]"
>
<span className="text-[13px] font-medium text-[rgb(var(--accent))]">
{selected.size} selected
</span>
<Button size="sm" variant="ghost" onClick={handleDelete}>
Delete
</Button>
<Button size="sm" variant="ghost" onClick={handleExport}>
Export
</Button>
</div>
)}
<table aria-label="Files" aria-multiselectable="true">
<thead>
<tr>
<th>
<input
type="checkbox"
aria-label="Select all"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked; }}
onChange={toggleAll}
/>
</th>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
aria-selected={selected.has(row.id)}
onClick={() => toggle(row.id)}
>
<td>
<input
type="checkbox"
aria-label={`Select ${row.name}`}
checked={selected.has(row.id)}
onChange={() => toggle(row.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td>{row.name}</td>
<td>{row.type}</td>
<td>{row.size}</td>
<td>{row.modified}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Guidelines
- →Show the bulk action toolbar only when items are selected — never as a permanent chrome element.
- →Include a selection count ('3 selected') in the toolbar so users know what scope the actions apply to.
- →Support Shift+Click range selection — it is the primary power-user flow and expected in any table.
- →Make rows clickable as an alternative to the checkbox — clicking anywhere on the row should toggle selection.
- →Escape should deselect all. This is the expected keyboard escape hatch across selection patterns.
- →For destructive actions (Delete), show a confirmation dialog when more than 5 items are selected.