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.
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> );}| Part | Treatment |
|---|---|
| Row checkbox | A 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 all | The header checkbox, always visible. Checked when every row in the view is selected, a dash when some are. |
| Selected row | A 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 bar | Popover 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. |
| Feedback | A 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.
| When people want to | Use | Why |
|---|---|---|
| Apply the same action to several records in a list | Row checkboxes and the action bar | The bar follows the selection as people scroll and keeps actions beside the count they affect. |
| Act on one record | A row menu or the record header, see Dropdown menu | Selecting one row to reach its actions costs two extra clicks. |
| Pick records as part of a form, such as invoices for a payment run | A checkbox list inside the form, with the count and total in its footer | The form's own submit button is the action. A floating bar would compete with it. |
| Mark the record that's open beside the list | activeRowId on the Data grid | The same tint without a checked box. Opening a record isn't selecting it. |
| Choose one option from a set | Radio group, Select or Choice card | Exclusive choices are values, not selections. |
| Act on selected text | Selection actions | Text 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.
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.
- Northwind Freight
- Halcyon Packaging
- Orchard Street
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.
| Gesture | Result |
|---|---|
| Click a checkbox or its row label | Toggles that row and makes it the anchor. |
| Shift-click another row | Sets every row between the anchor and this one to this row's new state. |
| Click the header checkbox | Selects every row that matches the current filters, or clears them when all are selected. |
| Click the bar's clear button | Clears the whole selection, including rows the filter hides. |
| Click a row outside its checkbox in a grid | Opens the record. It never changes the selection. |
| Keys | Action |
|---|---|
| Tab | Reaches each row checkbox in order. |
| Space | Toggles the focused checkbox. |
| ShiftSpace | Extends the selection from the anchor to this row. |
| Esc | Clears 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
The One Filled Button Rule
The Tabular Figures Rule
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.
nounis printed as given. - Two to four direct actions as ghost
smbuttons 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
relativewrapper 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.
sm actions, the destructive one last in red text, and a clear button at the end.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.
| The action | Selection afterwards | Feedback |
|---|---|---|
| Changes every record (approve, assign, move to stage) | Cleared. The bar leaves. | 8 invoices approved with Undo. |
| Changes some records and fails on others | Only 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
rowsyou 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.
Are you sure?
This action cannot be undone.
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-noneon rows, or the browser selects text between the two clicks.
Components#
The parts this pattern is built from.
- CheckboxA 16px square for independent on and off choices and row selection.
- Action barThe floating bar that rises when rows are selected, with the count and bulk actions.
- Data gridThe record table at Attio density: sticky headers, pinned columns, selection and resize.
- Confirm dialogThe standard confirmation for destructive actions, with an optional typed-name check.
- ToastBrief feedback after an action, with undo for anything reversible.
- Dropdown menuA menu of actions or options that opens from a button.
- Status labelAn 8px dot that always travels with a text label, in neutral, live, success, warning, danger and info tones.