ComponentsTables and views

Bulk action bar

While rows are selected, a 44px bar covers the toolbar: how many are selected, the actions that apply to all of them, then Clear.

Playground

Change the controls to try each prop. The preview gives the bar Archive and Merge; the code lists only the props you changed.

States
import { BulkActionBar } from '@/ui/bulk-action-bar';<BulkActionBar />

In the table

The bar takes the toolbar's place and height, so the grid doesn't move, and the toolbar under it is inert. Merge shows only with exactly two rows picked. Archive acts at once with Undo.

CandidateCompany
Maya ChenHalcyon Bank
Priya ShahNorthbridge Talent
Daniel OkaforHalcyon Bank
import { ArchiveIcon, FunnelSimpleIcon, GitMergeIcon, SlidersHorizontalIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Button } from '@/ui/button';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { moveList } from '@/ui/motion';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
import { toast } from '@/ui/toast';

type Candidate = { id: string; name: string; company: string };

const start: Candidate[] = [
  { id: 'c1', name: 'Maya Chen', company: 'Halcyon Bank' },
  { id: 'c2', name: 'Priya Shah', company: 'Northbridge Talent' },
  { id: 'c3', name: 'Daniel Okafor', company: 'Halcyon Bank' },
];

const columns: TableColumn<Candidate>[] = [
  { key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
  { key: 'company', label: 'Company', width: 200, cell: (row) => row.company },
];

// Pick rows: the bar covers the toolbar. Archive acts at once with Undo.
export default function BulkActionBarInTheTable() {
  const [rows, setRows] = useState(start);
  const [selected, setSelected] = useState<string[]>(['c1', 'c2']);
  const archive = () => {
    const gone = rows.filter((row) => selected.includes(row.id));
    moveList(() => {
      setRows(rows.filter((row) => !selected.includes(row.id)));
      setSelected([]);
    });
    toast.add({
      type: 'success',
      title: `${gone.length} ${gone.length === 1 ? 'candidate' : 'candidates'} archived`,
      actionProps: { children: 'Undo', onClick: () => moveList(() => setRows(start)) },
    });
  };
  return (
    <div className="flex w-full flex-col bg-surface">
      <div className="relative">
        <div inert={selected.length > 0 || undefined} className="flex h-11 items-center justify-between border-b border-border px-150">
          <Button variant="ghost">
            <FunnelSimpleIcon />
            Filter
          </Button>
          <Button variant="ghost">
            <SlidersHorizontalIcon />
            Display
          </Button>
        </div>
        {selected.length > 0 ? (
          <BulkActionBar count={selected.length} onClear={() => setSelected([])}>
            <BulkAction icon={<ArchiveIcon />} onClick={archive}>
              Archive
            </BulkAction>
            {selected.length === 2 ? <BulkAction icon={<GitMergeIcon />}>Merge</BulkAction> : null}
          </BulkActionBar>
        ) : null}
      </div>
      <Table label="Candidates" columns={columns} rows={rows} rowLabel={(row) => row.name} selectable selected={selected} onSelectedChange={setSelected} rowMotion />
    </div>
  );
}

Actions by table

Each table offers only what applies to every selected row: Archive and Fill from website on Companies, Approve and Deny on changes from Flo, Restore on the Archived tab. Approve is the one primary. While an action runs, every action disables.

import { ArrowCounterClockwiseIcon, ArchiveIcon, CheckIcon, GlobeIcon, XIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';

const clear = () => {};

// Each table offers only what applies to every selected row.
export default function BulkActionBarActionsByTable() {
  return (
    <div className="flex w-full flex-col gap-150">
      <BulkActionBar overlay={false} count={3} onClear={clear}>
        <BulkAction icon={<ArchiveIcon />}>Archive</BulkAction>
        <BulkAction icon={<GlobeIcon />}>Fill from website</BulkAction>
      </BulkActionBar>
      <BulkActionBar overlay={false} count={3} onClear={clear}>
        <BulkAction variant="primary" icon={<CheckIcon />}>Approve 3</BulkAction>
        <BulkAction icon={<XIcon />}>Deny</BulkAction>
      </BulkActionBar>
      <BulkActionBar overlay={false} count={5} busy onClear={clear}>
        <BulkAction icon={<ArrowCounterClockwiseIcon />}>Restore</BulkAction>
      </BulkActionBar>
    </div>
  );
}

Move to stage

Lists only the stages every selected application shares, each as its stage lozenge.

import { ArrowRightIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { Lozenge } from '@/ui/lozenge';
import { Popover, PopoverContent, PopoverTrigger } from '@/ui/popover';

const stages = [
  { label: 'Long list', phase: 'working' },
  { label: 'Short list', phase: 'working' },
  { label: 'CV sent', phase: 'client' },
  { label: 'First interview', phase: 'client' },
  { label: 'Offer', phase: 'offer' },
  { label: 'Placed', phase: 'placed' },
] as const;

// Move to stage lists only the stages every selected application shares.
export default function BulkActionBarMoveToStage() {
  return (
    <BulkActionBar overlay={false} count={4} onClear={() => {}} className="w-full">
      <Popover>
        <PopoverTrigger render={<BulkAction icon={<ArrowRightIcon />}>Move to stage</BulkAction>} />
        <PopoverContent align="start" className="w-52 gap-0 p-050">
          {stages.map((stage) => (
            <button key={stage.label} type="button" className="flex h-7 w-full cursor-pointer items-center rounded-medium px-100 outline-none motion-state hover:bg-background-hovered focus-visible:bg-background-hovered">
              <Lozenge phase={stage.phase}>{stage.label}</Lozenge>
            </button>
          ))}
        </PopoverContent>
      </Popover>
    </BulkActionBar>
  );
}

No shared stage

When the selected applications share no stage, Move to stage stays in its place, disabled, with the reason in its tooltip.

import { ArrowRightIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { Hint } from '@/ui/tooltip';

// Applications from Jobs with different rounds share no stage: the action stays, disabled, with the reason.
export default function BulkActionBarNoSharedStage() {
  return (
    <BulkActionBar overlay={false} count={2} onClear={() => {}} className="w-full">
      <Hint label="These applications share no stage. Select applications on Jobs with the same rounds.">
        <BulkAction icon={<ArrowRightIcon />} disabled>
          Move to stage
        </BulkAction>
      </Hint>
    </BulkActionBar>
  );
}

When an action partly fails

Press Archive: one of three can't be archived. The two that worked go, the one that failed stays selected, and an error toast says why and what to do. It stays until closed.

import { ArchiveIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { toast } from '@/ui/toast';

// Archive 3: one can't be archived. The two that worked go; the one that failed stays selected, and the toast says why.
export default function BulkActionBarActionFails() {
  const [count, setCount] = useState(3);
  const [busy, setBusy] = useState(false);
  const archive = () => {
    setBusy(true);
    setTimeout(() => {
      setBusy(false);
      setCount(1);
      toast.add({ type: 'error', title: "1 candidate wasn't archived", description: 'Daniel Okafor has an open placement. Close it first, then archive again.' });
    }, 900);
  };
  return (
    <BulkActionBar overlay={false} count={count} busy={busy} onClear={() => setCount(3)} className="w-full">
      <BulkAction icon={<ArchiveIcon />} onClick={archive}>
        Archive
      </BulkAction>
    </BulkActionBar>
  );
}

On this page