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

NameTypeSizeModified
design-tokens.jsonJSON48 KBToday
button.stories.tsxTSX12 KBToday
color-palette.figFigma2.4 MBYesterday
motion-spec.mdMD8 KBMay 6
component-index.csvCSV3 KBMay 5
accessibility-audit.pdfPDF1.1 MBMay 4

Selection states

StateHeader checkboxRow visualBulk bar
None selectedUncheckedDefault backgroundHidden
Some selectedIndeterminateAccent tint on selected rowsVisible with count
All selectedCheckedAll rows accent tintedVisible — 'N selected'

Keyboard & interactions

InputAction
Space on checkboxToggle selection for that row
Shift+ClickSelect range from last-selected to clicked row
Cmd/Ctrl+ASelect all rows
EscapeDeselect all
Delete / BackspaceDelete 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.