Skip to content

Pattern

Table toolbar

The strip above a table that holds search, filtering, sorting and bulk actions. Its job is to change mode cleanly: browsing and acting-on-a-selection are two different states, and the toolbar should look like it knows which one it is in.

Built fromInputButtonCheckboxDropdown MenuBadge

The patternPermalink to this section

Select a row to watch the toolbar switch into bulk-action mode, then clear the selection to switch back.

Projects, with selection checkboxes and status
ProjectRegionStatusUpdated
Q3 marketing siteEuropeDeployed2 hours ago
billing-serviceNorth AmericaDeployed5 hours ago
search-indexerAsia PacificBuilding12 minutes ago
legacy-webhooksNorth AmericaFailed3 days ago

Two modesPermalink to this section

One toolbar, two jobs. Swapping the contents rather than stacking a second bar keeps the table's position on the page stable.

  • Browsing — search on the left, filter and sort on the right. This is the resting state.
  • Selection — a clear-selection control, the count, and the bulk actions. Search and sort step aside.
  • The bar keeps the same height in both modes, so the table does not jump when a row is selected.
  • The count is the anchor: it says exactly what the bulk actions will apply to.

SelectionPermalink to this section

The header checkbox selects everything currently visible — not everything in the dataset. When the two differ, say so.

Nothing selected — header checkbox is empty.
Some selected — header checkbox is mixed.
All 4 visible rows selected.

Select all 128 projects — offered only when the filter hides more than is shown.

BehaviorPermalink to this section

  • Selecting a row highlights it with the brand-soft fill, so the selection is visible in the table as well as the count.
  • Clearing the selection returns focus to the header checkbox, not to the top of the page.
  • Destructive bulk actions open a confirmation dialog that names the count: “Delete 4 projects?”.
  • Changing a filter clears the selection, and says so — silently acting on rows that scrolled out of view is the bug this prevents.
  • The count sits in a role="status" region so selection changes are announced.
  • Keep bulk actions to three or fewer; put the rest behind an overflow menu.
  • Disable bulk actions that do not apply to every selected row, and say why in the label.

Do and don'tPermalink to this section

4 selected

Doshow the count with the actions

The count is what makes a bulk action safe to press — it states the blast radius.

Don'thide the count

Bulk actions with no visible count invite someone to delete far more than they meant to.

4 selected

Doswap modes in place

Replacing the toolbar’s contents keeps the table where it was, so nothing shifts under the cursor.

Search and filter
4 selected

Don'tstack a second bar

A second bar pushes the table down the moment a row is ticked, moving the next row out from under the pointer.

Code examplePermalink to this section

app/projects/projects-toolbar.tsx
"use client";

import { Button, Checkbox, IconButton, Input } from "@borneo/react";
import { Download, Search, Trash2, X } from "lucide-react";

export function ProjectsToolbar({ rows, selected, setSelected, query, setQuery }) {
  const allSelected = rows.length > 0 && selected.length === rows.length;
  const someSelected = selected.length > 0 && !allSelected;

  // One bar, two modes — the height stays constant so the
  // table never shifts when a row is ticked.
  if (selected.length > 0) {
    return (
      <div className="flex items-center gap-2 border-b p-2">
        <IconButton
          label="Clear selection"
          icon={<X />}
          size="sm"
          onClick={() => setSelected([])}
        />

        {/* Announced, so the count is perceivable without sight */}
        <span role="status" className="type-body-sm font-[550]">
          {selected.length} selected
        </span>

        <div className="ml-auto flex gap-1.5">
          <Button size="sm" variant="secondary" leadingIcon={<Download />}>
            Export
          </Button>
          <Button size="sm" variant="ghost" leadingIcon={<Trash2 />}>
            Delete
          </Button>
        </div>
      </div>
    );
  }

  return (
    <div className="flex items-center gap-2 border-b p-2">
      <Input
        size="sm"
        type="search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Search projects"
        aria-label="Search projects"
        leadingIcon={<Search />}
      />
    </div>
  );
}

Responsive behaviorPermalink to this section

The toolbar stacks below 640px, and the table itself scrolls horizontally rather than shrinking its columns into unreadability.

Projects, with selection checkboxes and status
ProjectRegionStatusUpdated
Q3 marketing siteEuropeDeployed2 hours ago
billing-serviceNorth AmericaDeployed5 hours ago
search-indexerAsia PacificBuilding12 minutes ago
legacy-webhooksNorth AmericaFailed3 days ago

Or turn rows into cards

Horizontal scrolling works for a table people scan. When each row is something to act on individually, a stack of cards below 640px is usually better — the toolbar and selection model stay the same.

AccessibilityPermalink to this section

  • The table is a real table with a caption, th scope attributes and one row per record.
  • Every row checkbox has a unique name (“Select billing-service”), never just “Select”.
  • The header checkbox reports aria-checked="mixed" when the selection is partial.
  • The selection count lives in a role="status" region so it is announced as it changes.
  • Selected rows are marked with a data attribute and a background fill, and the checkbox state is the authoritative signal.
  • The horizontal scroll container is focusable, so a keyboard user can scroll a wide table.
  • Bulk actions are real buttons in the tab order, reachable without a pointer.