Skip to content

Selection and bulk actions

Row selection, the floating action bar and what happens to a selection as views change.

The problem#

People rarely act on one invoice at a time. They clear a supplier's backlog, approve a week of matched invoices, or export everything on hold for a review.

Doing that row by row is slow and error-prone, and every list that invents its own selection teaches a different gesture. Canon solves it once: checkboxes that stay out of the way until they're needed, a quiet indigo tint on what's selected, and one floating bar that counts the selection and holds the actions that apply to all of it.

The solution#

Row checkboxes, a 6% indigo tint, and the action bar rising from the bottom of the list. The bar is the only place bulk actions live.

A selectable invoice list

Hover a row to reveal its checkbox, or shift-click to select a range. Switch the filter and the selection stays, with the bar saying how many are hidden. Approve leaves the invoices missing a W-9 selected; Void asks first and names the count. Escape clears.

7 invoices
SupplierAmount
import { ActionBar } from "@oration/canon/components/action-bar";import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { StatusLabel, type StatusTone } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { BanIcon, CheckIcon, DownloadIcon } from "lucide-react";import * as React from "react";export function InvoiceSelection() {    type Status = "Matched" | "Missing W-9" | "Approved";    type Invoice = {        id: string;        supplier: string;        color: "blue" | "teal" | "orange" | "violet" | "green" | "pink";        due: string;        status: Status;        cents: number;    };    const seed: Invoice[] = [        {            id: "INV-20417",            supplier: "Northwind Freight",            color: "blue",            due: "Oct 2",            status: "Matched",            cents: 1824000,        },        {            id: "INV-20431",            supplier: "Halcyon Packaging",            color: "teal",            due: "Oct 5",            status: "Missing W-9",            cents: 491250,        },        {            id: "INV-20438",            supplier: "Orchard Street Foods",            color: "orange",            due: "Oct 6",            status: "Matched",            cents: 730518,        },        {            id: "INV-20442",            supplier: "Pinecrest Supply",            color: "violet",            due: "Oct 9",            status: "Matched",            cents: 126400,        },        {            id: "INV-20447",            supplier: "Bayline Logistics",            color: "green",            due: "Oct 9",            status: "Missing W-9",            cents: 268900,        },        {            id: "INV-20451",            supplier: "Northwind Freight",            color: "blue",            due: "Oct 12",            status: "Matched",            cents: 942075,        },        {            id: "INV-20456",            supplier: "Granite Peak Office",            color: "pink",            due: "Oct 14",            status: "Matched",            cents: 58340,        },    ];    const tone: Record<Status, StatusTone> = {        Matched: "neutral",        "Missing W-9": "warning",        Approved: "success",    };    const [invoices, setInvoices] = React.useState(seed);    const [filter, setFilter] = React.useState<"all" | "matched" | "hold">(        "all",    );    const [selected, setSelected] = React.useState<Set<string>>(        () => new Set(),    );    const [confirmOpen, setConfirmOpen] = React.useState(false);    const anchor = React.useRef<string | null>(null);    const shift = React.useRef(false);    const visible = invoices.filter((invoice) =>        filter === "all"            ? true            : filter === "matched"              ? invoice.status === "Matched"              : invoice.status === "Missing W-9",    );    const visibleIds = visible.map((invoice) => invoice.id);    const selectedVisible = visibleIds.filter((id) => selected.has(id)).length;    const hidden = selected.size - selectedVisible;    const count = selected.size;    const noun = (n: number) => (n === 1 ? "invoice" : "invoices");    const money = (cents: number) =>        (cents / 100).toLocaleString("en-US", {            style: "currency",            currency: "USD",        });    const toggle = (id: string, checked: boolean) => {        const from = anchor.current ? visibleIds.indexOf(anchor.current) : -1;        const to = visibleIds.indexOf(id);        const range =            shift.current && from !== -1 && to !== -1                ? visibleIds.slice(Math.min(from, to), Math.max(from, to) + 1)                : [id];        anchor.current = id;        setSelected((current) => {            const next = new Set(current);            for (const rowId of range) {                if (checked) next.add(rowId);                else next.delete(rowId);            }            return next;        });    };    const toggleAll = (checked: boolean) =>        setSelected((current) => {            const next = new Set(current);            for (const id of visibleIds) {                if (checked) next.add(id);                else next.delete(id);            }            return next;        });    const clear = () => {        setSelected(new Set());        anchor.current = null;    };    const approve = () => {        const before = invoices;        const targets = invoices.filter((invoice) => selected.has(invoice.id));        const blocked = targets.filter(            (invoice) => invoice.status === "Missing W-9",        );        const done = targets.length - blocked.length;        setInvoices((current) =>            current.map((invoice) =>                selected.has(invoice.id) && invoice.status !== "Missing W-9"                    ? { ...invoice, status: "Approved" as const }                    : invoice,            ),        );        setSelected(new Set(blocked.map((invoice) => invoice.id)));        toast.add({            type: blocked.length ? undefined : "success",            title: `${done} ${noun(done)} approved`,            description: blocked.length                ? `${blocked.length} ${noun(blocked.length)} on hold for a missing W-9 ${blocked.length === 1 ? "stays" : "stay"} selected.`                : "They go out in Thursday's payment run.",            actionProps: {                children: "Undo",                onClick: () => setInvoices(before),            },        });    };    const exportSelected = () =>        toast.add({            title: `Exporting ${count} ${noun(count)}`,            description: "invoices-sep-28.csv downloads when it's ready.",        });    const voidSelected = () => {        const before = invoices;        const n = count;        setInvoices((current) =>            current.filter((invoice) => !selected.has(invoice.id)),        );        clear();        toast.add({            title: `${n} ${noun(n)} voided`,            description: "They're out of Thursday's payment run.",            actionProps: {                children: "Undo",                onClick: () => setInvoices(before),            },        });    };    return (        <section            aria-label="Invoices due this week"            onKeyDown={(event) => {                if (event.key === "Escape" && count > 0 && !confirmOpen)                    clear();            }}            className="relative w-full max-w-2xl overflow-hidden rounded-xl bg-card text-left shadow-border"        >            <div className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">                <SegmentedControl                    label="Show invoices"                    value={filter}                    onValueChange={setFilter}                    options={[                        { value: "all", label: "All" },                        { value: "matched", label: "Matched" },                        { value: "hold", label: "On hold" },                    ]}                />                <span className="text-xs text-muted-foreground tabular-nums">                    {visible.length} {noun(visible.length)}                </span>            </div>            <div className="flex h-8 items-center gap-3 border-b border-border px-4 text-xs font-medium text-muted-foreground">                <Checkbox                    aria-label="Select all invoices in this view"                    checked={                        visible.length > 0 && selectedVisible === visible.length                    }                    indeterminate={                        selectedVisible > 0 && selectedVisible < visible.length                    }                    onCheckedChange={(checked) => toggleAll(checked)}                />                <span className="ml-9 flex-1">Supplier</span>                <span className="hidden w-28 sm:block">Status</span>                <span className="w-24 text-right">Amount</span>            </div>            <ul                aria-label="Invoices"                onPointerDownCapture={(event) => {                    shift.current = event.shiftKey;                }}                onKeyDownCapture={(event) => {                    shift.current = event.shiftKey;                }}                className="min-h-72 pb-[72px]"            >                {visible.map((invoice) => {                    const isSelected = selected.has(invoice.id);                    return (                        <li                            key={invoice.id}                            className="border-b border-border last:border-b-0"                        >                            <label                                className={cn(                                    "group/row flex min-h-11 cursor-pointer items-center gap-3 px-4 py-2 transition-colors duration-150 select-none",                                    isSelected                                        ? "bg-primary/6"                                        : "hover:bg-surface",                                )}                            >                                <Checkbox                                    checked={isSelected}                                    onCheckedChange={(checked) =>                                        toggle(invoice.id, checked)                                    }                                    aria-label={`Select ${invoice.id} from ${invoice.supplier}`}                                    className={cn(                                        count === 0 &&                                            "opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100",                                    )}                                />                                <MonogramTile                                    name={invoice.supplier}                                    color={invoice.color}                                />                                <span className="flex min-w-0 flex-1 flex-col">                                    <span className="truncate text-13 font-medium text-foreground">                                        {invoice.supplier}                                    </span>                                    <span className="flex gap-3 text-xs text-muted-foreground">                                        <span className="font-mono">                                            {invoice.id}                                        </span>                                        <span>Due {invoice.due}</span>                                    </span>                                </span>                                <span className="hidden w-28 sm:block">                                    <StatusLabel tone={tone[invoice.status]}>                                        {invoice.status}                                    </StatusLabel>                                </span>                                <span className="w-24 text-right text-13 font-medium text-foreground tabular-nums">                                    {money(invoice.cents)}                                </span>                            </label>                        </li>                    );                })}            </ul>            <ActionBar                count={count}                noun={`${noun(count)} selected`}                onClear={clear}            >                {hidden > 0 ? (                    <span className="px-1.5 text-xs whitespace-nowrap text-muted-foreground tabular-nums">                        {hidden} not in this view                    </span>                ) : null}                <Button variant="ghost" size="sm" onClick={approve}>                    <CheckIcon data-icon="inline-start" aria-hidden="true" />                    Approve                </Button>                <Button variant="ghost" size="sm" onClick={exportSelected}>                    <DownloadIcon data-icon="inline-start" aria-hidden="true" />                    Export                </Button>                <Button                    variant="ghost"                    size="sm"                    onClick={() => setConfirmOpen(true)}                    className="text-destructive hover:text-destructive"                >                    <BanIcon data-icon="inline-start" aria-hidden="true" />                    Void                </Button>            </ActionBar>            <ConfirmDialog                open={confirmOpen}                onOpenChange={setConfirmOpen}                title={`Void ${count} ${noun(count)}?`}                description={                    hidden > 0                        ? `This includes ${hidden} that the current filter hides. Voided invoices leave Thursday's payment run, and suppliers aren't told.`                        : "Voided invoices leave Thursday's payment run, and suppliers aren't told."                }                confirmLabel={`Void ${count} ${noun(count)}`}                onConfirm={voidSelected}            />        </section>    );}
The parts and their Canon values
PartTreatment
Row checkboxA 16px Checkbox with a 4px corner. Hidden at rest, it fades in on row hover and on keyboard focus, and every row shows one once anything is selected. Always visible on touch.
Select allThe header checkbox, always visible. Checked when every row in the view is selected, a dash when some are.
Selected rowA 6% indigo tint on every cell of the row (bg-primary/6, or color-mix(in oklch, var(--primary) 6%, var(--background)) in the grid). Hover is Row Mist (bg-surface) and never stacks on the tint.
Action barPopover White, 12px corners, the floating bar shadow (shadow-popover), centered 20px above the bottom of its relative container. Count in 13px medium tabular figures, ghost sm actions, hairline separators, a clear button.
FeedbackA toast that repeats the count and the outcome, with Undo when the action can be reversed.

Choosing a selection model#

Selection with a floating bar is for acting on records in a list. Several nearby jobs look similar and want something else.

Which selection model to use
When people want toUseWhy
Apply the same action to several records in a listRow checkboxes and the action barThe bar follows the selection as people scroll and keeps actions beside the count they affect.
Act on one recordA row menu or the record header, see Dropdown menuSelecting one row to reach its actions costs two extra clicks.
Pick records as part of a form, such as invoices for a payment runA checkbox list inside the form, with the count and total in its footerThe form's own submit button is the action. A floating bar would compete with it.
Mark the record that's open beside the listactiveRowId on the Data gridThe same tint without a checked box. Opening a record isn't selecting it.
Choose one option from a setRadio group, Select or Choice cardExclusive choices are values, not selections.
Act on selected textSelection actionsText selection gets its own floating toolbar beside the text.

Selecting inside a form

When picking is the job of the form, the count and total sit in its footer and the form's submit is the one filled button. No action bar.

Invoices to add to the run

Add invoices to Thursday's run

Matched invoices due before October 15.

1 selected, $18,240.00

import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function PaymentRunPicker() {    const invoices = [        { id: "INV-20417", supplier: "Northwind Freight", cents: 1824000 },        { id: "INV-20438", supplier: "Orchard Street Foods", cents: 730518 },        { id: "INV-20442", supplier: "Pinecrest Supply", cents: 126400 },        { id: "INV-20456", supplier: "Granite Peak Office", cents: 58340 },    ];    const [picked, setPicked] = React.useState<string[]>(["INV-20417"]);    const money = (cents: number) =>        (cents / 100).toLocaleString("en-US", {            style: "currency",            currency: "USD",        });    const total = invoices        .filter((invoice) => picked.includes(invoice.id))        .reduce((sum, invoice) => sum + invoice.cents, 0);    return (        <fieldset className="w-full max-w-md overflow-hidden rounded-xl bg-card text-left shadow-border">            <legend className="sr-only">Invoices to add to the run</legend>            <div className="border-b border-border px-4 py-3">                <p className="text-sm font-semibold text-foreground">                    Add invoices to Thursday's run                </p>                <p className="text-13 text-muted-foreground">                    Matched invoices due before October 15.                </p>            </div>            <ul className="p-2">                {invoices.map((invoice) => (                    <li key={invoice.id}>                        <label className="flex h-9 cursor-pointer items-center gap-3 rounded-lg px-2 text-13 transition-colors duration-150 hover:bg-muted">                            <Checkbox                                checked={picked.includes(invoice.id)}                                onCheckedChange={(checked) =>                                    setPicked((current) =>                                        checked                                            ? [...current, invoice.id]                                            : current.filter(                                                  (id) => id !== invoice.id,                                              ),                                    )                                }                            />                            <span className="min-w-0 flex-1 truncate text-foreground">                                {invoice.supplier}                            </span>                            <span className="font-mono text-xs text-muted-foreground">                                {invoice.id}                            </span>                            <span className="w-24 text-right font-medium tabular-nums">                                {money(invoice.cents)}                            </span>                        </label>                    </li>                ))}            </ul>            <div className="flex items-center justify-between gap-3 border-t border-border bg-muted/40 px-4 py-2.5">                <p className="text-13 text-muted-foreground tabular-nums">                    <span className="font-medium text-foreground">                        {picked.length}                    </span>{" "}                    selected, {money(total)}                </p>                <Button                    disabled={picked.length === 0}                    onClick={() =>                        toast.add({                            type: "success",                            title: `Added ${picked.length} ${picked.length === 1 ? "invoice" : "invoices"} to the run`,                            description: `${money(total)} goes out Thursday, October 1.`,                        })                    }                >                    Add to run                </Button>            </div>        </fieldset>    );}

Selecting rows#

Every gesture people already know from mail and file managers works the same way here.

Checkboxes on demand#

A column of empty boxes on every row is noise most of the time. Show a row's checkbox on hover and on focus, and show them all once anything is selected so people can see what they're adding to.

Do. Reveal the checkbox on hover or focus, and show every checkbox once one row is selected. Hover the rows to try it.
  • Northwind Freight
  • Halcyon Packaging
  • Orchard Street
Don't. Draw an empty checkbox on every row at rest. It adds a column of chrome to a list people mostly read.

Gestures#

The anchor is the last row someone clicked. Shift-click selects or clears every row between the anchor and the click, in the order the view shows them.

Pointer gestures
GestureResult
Click a checkbox or its row labelToggles that row and makes it the anchor.
Shift-click another rowSets every row between the anchor and this one to this row's new state.
Click the header checkboxSelects every row that matches the current filters, or clears them when all are selected.
Click the bar's clear buttonClears the whole selection, including rows the filter hides.
Click a row outside its checkbox in a gridOpens the record. It never changes the selection.
Keyboard interactions
KeysAction
TabReaches each row checkbox in order.
SpaceToggles the focused checkbox.
ShiftSpaceExtends the selection from the anchor to this row.
EscClears the selection while focus is in the list or the bar.

The selected tint#

Selection is one of the few places indigo is spent. A 6% tint is enough to see which rows are in without drowning their content, and the checked box carries the state for people who can't see the tint.

The Quiet Indigo Rule

Indigo is spent on the view's one filled primary action, on selection, on focus, and on live state that carries a text label (running, active, upcoming), plus the viewer's own markers: the unread dot, an @mention of you, the unsaved-changes dot. It never fills data, tints an icon tile, colors a heading or decorates.

The One Filled Button Rule

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

The Tabular Figures Rule

Every number that changes or gets compared (amounts, counts, percentages, times, credits) is set in tabular figures, and numeric columns align right.

DESIGN.md describes the tint two ways

Its color section pairs the 6% tint on selected grid rows with a 22% inset indigo ring; its Data grid section and the shipped grid draw the tint alone. Canon follows the Data grid section for rows, because rings stack into thick bars between adjacent selected rows. Active filter chips keep the ring.

The action bar#

One floating bar per list. It appears when the selection goes above zero and leaves when it returns to zero.

  • Name what's selected: 3 invoices selected, not 3 selected, and handle the singular yourself. noun is printed as given.
  • Two to four direct actions as ghost sm buttons with short verbs (Approve, Export, Void). Anything more goes in a More menu that opens upward.
  • A destructive action is ghost with Signal Red text, sits last before the clear button and always confirms.
  • Mount the bar inside the list's relative wrapper and give the list about 72px of bottom padding so the last row scrolls clear of it.
  • The bar never takes an indigo fill and never holds a filled button. The page's own primary action stays the only one.
3 invoices selected
Do. Ghost sm actions, the destructive one last in red text, and a clear button at the end.
3 invoices selected
Don't. Fill the actions. Three indigo buttons on the most elevated surface on screen outshout the page they act on.

Where the action bar drifts

It enters on its own 300ms spring instead of spring.slow, and it doesn't reduce motion. Its clear button has no tooltip, and there is no destructive action variant, so product bars pass text-destructive hover:text-destructive to a ghost Button as the example does. See the Action bar known gaps.

After an action#

What happens to the selection depends on what the action did to the records.

Selection after each kind of action
The actionSelection afterwardsFeedback
Changes every record (approve, assign, move to stage)Cleared. The bar leaves.8 invoices approved with Undo.
Changes some records and fails on othersOnly the failures stay selected, so the next action targets them.3 invoices approved. 1 on hold for a missing W-9 stays selected.
Removes records (void, archive, delete)Cleared, after a confirmation that names the count.3 invoices voided with Undo when the change can be reversed.
Reads records (export, copy, print)Kept, so people can act on the same set again.Exporting 12 invoices with the file name.
Leaves the list (opens a record, changes page in the app)Cleared. Selection never follows people to another place.None.

Across filters and pages#

Selection belongs to records, not to the view. People narrow a list to find more rows to add, so a filter change mustn't throw away what they picked.

  • Keep selected records selected when the filter, sort or search changes. The bar counts all of them and says how many the view hides: 2 not in this view.
  • Select all means every record that matches the current filters. In the Data grid it selects the rows you pass, so pass the filtered rows.
  • When a list loads in pages, select all first takes the loaded rows, then offers the rest in one line: All 50 shown are selected. Select all 1,284 matching invoices.
  • A confirmation for an action that reaches hidden records says so: This includes 2 that the current filter hides.
  • Clear the selection when people leave the list. Coming back starts fresh.

Destructive bulk actions#

Every destructive bulk action confirms, and the count travels from the bar into the dialog title and the confirm button. See Destructive actions.

Void 3 invoices?

Voided invoices leave Thursday's payment run, and suppliers aren't told.

Do. Put the count and the noun in the title and on the button, and say what happens next.

Are you sure?

This action cannot be undone.

Don't. Ask whether they're sure. A generic question with OK reads the same for one invoice and for three hundred.

Use ConfirmDialog with the default destructive tint. Focus starts on Cancel. For a permanent delete of many records, pass confirmText so the button stays disabled until the exact phrase is typed. If the action can be undone, prefer acting straight away with Undo in the toast over a confirmation.

Accessibility#

The checkbox carries the state; everything else is a visual aid.

  • Name each row checkbox after its record: Select INV-20431 from Halcyon Packaging. The shipped grid labels every row Select row, which screen readers can't tell apart; see the Data grid known gaps.
  • Hidden checkboxes are hidden with opacity, never removed, so they stay in the tab order and appear on focus-visible.
  • The tint is never the only signal. The checked box reads in grayscale and to assistive tech.
  • The action bar is role="toolbar" named Bulk actions. It isn't announced when it appears; the checked boxes already announce the selection.
  • After a bulk action, the toast is announced politely. Undo is reachable from the keyboard while the toast is open.
  • Shift-click needs select-none on rows, or the browser selects text between the two clicks.

Components#

The parts this pattern is built from.