Bulk Actions
Let users select many records and act on all of them at once.
Bulk actions let users select several records in a collection and run one operation across all of them, such as deleting outdated events, pausing ticket sales, or moving many events to a new venue at once. They turn a repetitive row-by-row chore into a single decision, which makes them one of the highest-leverage patterns in data-heavy admin interfaces.
They are also one of the most dangerous: a single press now affects many records. The pattern therefore follows one defining principle: users act only on what they can see. Every stage below reinforces it, from a selection that never silently includes hidden rows, to a confirmation that names the exact count, to a result message that reports precisely what happened. If you are unsure about a design decision within this pattern, ask whether the user can still see what they are about to touch.
This page walks through the pattern in the order you build it: the layout, the actions, the selection, running an action, and reporting its outcome. For managing individual records in a table, see the Table Records pattern. For narrowing which records are shown, see the Filter pattern.
When to use
Use bulk actions when users repeatedly apply the same operation to many similar records. The pattern earns its complexity through repetition: the more records a task touches, the more a single selection and press saves over row-by-row work.
When not to use
Skip the pattern when:
- The context is single-record. A detail page or a short list where users act on one item at a time needs a
<Button>or<ButtonGroup>, not a selection model. - The operation needs a per-record decision. If the user must review or enter something different for each record, a bulk form flattens differences that matter. Reach for the editing flows in Table Records instead.
- The selection is heterogeneous. When an action is valid for some selected records and not others, either disable it with an explanation or design the operation per record type. Silently skipping records breaks the "act on what you see" principle.
- The operation affects records the user cannot see. An action whose blast radius extends beyond the visible selection, such as cascading deletes across related data, needs its impact surfaced in the confirmation, or it does not belong in a bulk flow.
Structure
The pattern arranges five areas. The collection displays the records with a checkbox column, the action bar floats above the collection while something is selected, and the confirmation dialog, drawer, and toast appear only at the moments they are needed.
- Select All: The header checkbox selects every visible row and shows an indeterminate state when only some rows are selected.
- Selection: Checkboxes on each row, with selected rows visually highlighted so the scope of the next action stays visible.
- Action Bar: A floating
<ActionBar>that appears once something is selected. It states the selection count, offers the actions, and carries a clear button to dismiss the selection. - Confirm Dialog: A
<Dialog>that interrupts destructive actions with the exact count and impact before anything happens. - Result Toast: A
<Toast>that reports the outcome after the operation completes.
Each stage of the flow maps to an existing Marigold component, so the pattern is composed rather than installed:
| Stage | The user... | Built with |
|---|---|---|
| Selection | picks the records to act on | <Table> with selectionMode="multiple" |
| Action choice | picks an operation for the selection | <ActionBar> via the Table's actionBar prop |
| Configuration | fills in values for a multi-field edit | <Drawer> opened from an action |
| Confirmation | approves a destructive operation | <Dialog> via the useConfirmation hook |
| Execution | waits for the operation to finish | <Button> loading or an inline <ProgressCircle> |
| Result | learns what happened | <Toast> plus the retained selection on failures |
Choosing actions
Not every operation earns a place in the action bar. Offer bulk actions for tasks users genuinely apply to many records at once, not speculatively for every operation a record supports. A bar crowded with rarely used actions slows down the frequent ones.
Name every action with a verb, or a verb plus noun where the target is ambiguous, so its effect is obvious before pressing: "Publish", "Assign category", "Delete". Avoid vague labels such as "Apply" or "OK" on their own. Order the actions from safest to most consequential and place a destructive action such as delete last, visually separated from the routine ones. When a selection carries more actions than fit comfortably, move the long tail into an <ActionMenu> rather than crowding the bar.
While the bar is visible, the selection is the object the user works on. Avoid competing per-row actions during that time: a row-level delete next to a bulk delete makes it unclear which scope a press affects. Bulk and per-row actions can still share a table when they take turns: one-off operations belong in a row menu (see Table Records), repetitive ones in the bar, and while rows are selected the row actions disable so the bar holds the only pressable scope.
Do
Offer a few high-value actions with clear verb labels, ordered from safe to destructive.
Don't
Don't mirror every single-record operation into the bar or mix bulk and per-row actions for the same verb at the same time.
Selection
Selection begins the flow, and it must be visible and cheap to change. Use a <Table> with selectionMode="multiple" and pass the <ActionBar> through its actionBar prop. These two components cover the selection mechanics on their own, so the work in this pattern lies in the decisions around them: which actions to offer, how far the selection reaches, and what happens after a press.
Event | Date | Venue | Status | |
|---|---|---|---|---|
Jazz Night | Aug 12, 2026 | Jazzhaus Freiburg | On sale | |
Open Air Kino | Aug 15, 2026 | Mensagarten | On sale | |
Poetry Slam | Aug 21, 2026 | Vorderhaus | Draft | |
Herbstmarkt | Sep 5, 2026 | Messe Freiburg | Draft | |
Wine Tasting | Sep 12, 2026 | Alte Wache | On sale | |
Klassik im Park | Sep 19, 2026 | Stadtgarten | On sale |
import { ActionBar, Badge, Button, Table } from '@marigold/components';import { Archive, Tag } from '@marigold/icons';const events = [ { id: '1', name: 'Jazz Night', date: 'Aug 12, 2026', venue: 'Jazzhaus Freiburg', status: 'On sale', }, { id: '2', name: 'Open Air Kino', date: 'Aug 15, 2026', venue: 'Mensagarten', status: 'On sale', }, { id: '3', name: 'Poetry Slam', date: 'Aug 21, 2026', venue: 'Vorderhaus', status: 'Draft', }, { id: '4', name: 'Herbstmarkt', date: 'Sep 5, 2026', venue: 'Messe Freiburg', status: 'Draft', }, { id: '5', name: 'Wine Tasting', date: 'Sep 12, 2026', venue: 'Alte Wache', status: 'On sale', }, { id: '6', name: 'Klassik im Park', date: 'Sep 19, 2026', venue: 'Stadtgarten', status: 'On sale', },];export default () => ( <Table aria-label="Events" // These two props are the whole mechanism: the Table owns selection, // and the bar with the actions rides along in the actionBar prop. selectionMode="multiple" // [!code highlight] // [!code highlight:1] actionBar={() => ( <ActionBar> <Button onPress={() => alert('Assign category')}> <Tag /> Assign category </Button> <Button onPress={() => alert('Archive')}> <Archive /> Archive </Button> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Date</Table.Column> <Table.Column>Venue</Table.Column> <Table.Column>Status</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.date}</Table.Cell> <Table.Cell>{event.venue}</Table.Cell> <Table.Cell> <Badge variant={event.status === 'On sale' ? 'success' : 'info'}> {event.status} </Badge> </Table.Cell> </Table.Row> ))} </Table.Body> </Table>);Scope
The selection is bounded to what the user can currently see. The select-all checkbox selects the visible rows, not the entire result set behind pagination, and there is no banner offering to extend the selection across all pages. When pagination, a filter, or a search query changes the visible set, clear the selection instead of carrying invisible rows along.
This is a deliberately conservative choice. Cross-page selection invites a well-known failure: the interface looks identical whether twenty visible rows or thousands of invisible ones are selected, and users act on records they never saw. Re-selecting after a page change costs a few clicks. Deleting a thousand records you did not know were selected costs much more, and there is no undo to catch it.
Event | Date | Status | |
|---|---|---|---|
Jazz Night | Aug 12, 2026 | On sale | |
Open Air Kino | Aug 15, 2026 | On sale | |
Poetry Slam | Aug 21, 2026 | Draft | |
Herbstmarkt | Sep 5, 2026 | Draft | |
Wine Tasting | Sep 12, 2026 | On sale |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Badge, Button, Select, Stack, Table,} from '@marigold/components';import { Archive, Tag } from '@marigold/icons';const events = [ { id: '1', name: 'Jazz Night', date: 'Aug 12, 2026', status: 'On sale', }, { id: '2', name: 'Open Air Kino', date: 'Aug 15, 2026', status: 'On sale', }, { id: '3', name: 'Poetry Slam', date: 'Aug 21, 2026', status: 'Draft', }, { id: '4', name: 'Herbstmarkt', date: 'Sep 5, 2026', status: 'Draft', }, { id: '5', name: 'Wine Tasting', date: 'Sep 12, 2026', status: 'On sale', },];export default () => { const [status, setStatus] = useState('all'); const [selected, setSelected] = useState<Selection>(new Set()); const visible = status === 'all' ? events : events.filter(event => event.status === status); // Clearing here, next to setStatus, is what keeps the selection from // outliving the view it was made in. One wrapper, so it can't be missed. const changeFilter = (nextStatus: string) => { setSelected(new Set()); // [!code highlight] setStatus(nextStatus); }; return ( <Stack space={4}> <Select label="Status" value={status} onChange={key => changeFilter(String(key))} width={44} > <Select.Option id="all">All statuses</Select.Option> <Select.Option id="On sale">On sale</Select.Option> <Select.Option id="Draft">Draft</Select.Option> </Select> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> <Button onPress={() => alert('Assign category')}> <Tag /> Assign category </Button> <Button onPress={() => alert('Archive')}> <Archive /> Archive </Button> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Date</Table.Column> <Table.Column>Status</Table.Column> </Table.Header> <Table.Body> {visible.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.date}</Table.Cell> <Table.Cell> <Badge variant={event.status === 'On sale' ? 'success' : 'info'} > {event.status} </Badge> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> </Stack> );};Pagination
Pagination puts the scope rule under the most pressure, because the select-all checkbox becomes ambiguous: does it select the rows on screen, or every row the query matched? Users guess wrong in both directions, and either wrong guess is expensive. In this pattern the answer is fixed: the header checkbox selects the current page, nothing more. A single click must never grow the selection beyond the rows the user can see, and no click selects records on pages the user never opened.
Moving to another page is a scope change like filtering, so it clears the selection, and the clearing must be perceivable: the action bar with its count disappears, which tells the user their selection is gone before they can act on a stale one. Changing the page size or the sort order swaps which rows are visible in the same way and clears the selection too. What must never happen is the silent middle ground, where rows checked on page one invisibly remain selected while the user looks at page two. The interface would show an unchecked page while the next press still reaches the hidden rows.
The demo pairs the table with the <Pagination> component. The clearing wrapper around the page setter is the same one the scope demo uses for filters:
Event | Date | Status | |
|---|---|---|---|
Jazz Night | Aug 12, 2026 | On sale | |
Open Air Kino | Aug 15, 2026 | On sale | |
Poetry Slam | Aug 21, 2026 | Draft | |
Herbstmarkt | Sep 5, 2026 | Draft |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Badge, Button, Pagination, Stack, Table,} from '@marigold/components';import { Archive, Tag } from '@marigold/icons';const pageSize = 4;const events = [ { id: '1', name: 'Jazz Night', date: 'Aug 12, 2026', status: 'On sale' }, { id: '2', name: 'Open Air Kino', date: 'Aug 15, 2026', status: 'On sale' }, { id: '3', name: 'Poetry Slam', date: 'Aug 21, 2026', status: 'Draft' }, { id: '4', name: 'Herbstmarkt', date: 'Sep 5, 2026', status: 'Draft' }, { id: '5', name: 'Wine Tasting', date: 'Sep 12, 2026', status: 'On sale' }, { id: '6', name: 'Krimi Dinner', date: 'Sep 19, 2026', status: 'On sale' }, { id: '7', name: 'Comedy Club', date: 'Sep 26, 2026', status: 'Draft' }, { id: '8', name: 'Filmkonzert', date: 'Oct 3, 2026', status: 'On sale' }, { id: '9', name: 'Lesung am See', date: 'Oct 10, 2026', status: 'Draft' },];export default () => { const [page, setPage] = useState(1); const [selected, setSelected] = useState<Selection>(new Set()); const visible = events.slice((page - 1) * pageSize, page * pageSize); // A page change is a scope change: clearing here keeps the selection from // silently including rows that are no longer on screen. const changePage = (nextPage: number) => { setSelected(new Set()); // [!code highlight] setPage(nextPage); }; return ( <Stack space={4}> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> <Button onPress={() => alert('Assign category')}> <Tag /> Assign category </Button> <Button onPress={() => alert('Archive')}> <Archive /> Archive </Button> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Date</Table.Column> <Table.Column>Status</Table.Column> </Table.Header> <Table.Body> {visible.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.date}</Table.Cell> <Table.Cell> <Badge variant={event.status === 'On sale' ? 'success' : 'info'} > {event.status} </Badge> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> <Pagination page={page} totalItems={events.length} pageSize={pageSize} onChange={changePage} /> </Stack> );};Do
Bound select-all to the visible page and clear the selection visibly when the page, page size, or sort order changes.
Don't
Don't carry an invisible selection across pages or let a single click select records on pages the user never opened.
Working with very large sets
When users regularly need to act on more records than fit on a page, do not reach for cross-page selection. Give them a filter that narrows the set to exactly the records in question, then let them select all visible results. The Filter pattern keeps the acted-on set reviewable, which a hidden selection never is.
If a product decision nevertheless requires acting on every matching record, the only safe shape is a disclosure in two steps. The checkbox selects the page as usual, and a separate, explicitly worded control ("Select all 3,200 results matching this filter") extends the scope. That control states the exact count. The operation has a clear upper limit that the interface shows instead of silently cutting records off, and the confirmation repeats the full count before anything runs. The header checkbox itself must never jump to the full result set.
Direct actions
Most bulk actions are a single verb that needs no further input: publish, pause, assign, export. Run them straight from the bar the moment they are pressed. Making users confirm a routine, recoverable operation adds friction without safety, and trains them to click through the dialogs that guard the operations that do matter.
After the operation succeeds, confirm the outcome with a <Toast> that names the count, and clear the selection. The cleared selection signals that the job is done and prevents the previous scope from silently applying to the next action.
Event | Date | Status | |
|---|---|---|---|
Jazz Night | Aug 12, 2026 | Draft | |
Open Air Kino | Aug 15, 2026 | Draft | |
Poetry Slam | Aug 21, 2026 | On sale | |
Herbstmarkt | Sep 5, 2026 | Draft | |
Wine Tasting | Sep 12, 2026 | On sale |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Badge, Button, Table, ToastProvider, useToast,} from '@marigold/components';import { CircleCheck, CirclePause } from '@marigold/icons';interface Event { id: string; name: string; date: string; status: 'On sale' | 'Paused' | 'Draft';}const initialEvents: Event[] = [ { id: '1', name: 'Jazz Night', date: 'Aug 12, 2026', status: 'Draft' }, { id: '2', name: 'Open Air Kino', date: 'Aug 15, 2026', status: 'Draft' }, { id: '3', name: 'Poetry Slam', date: 'Aug 21, 2026', status: 'On sale' }, { id: '4', name: 'Herbstmarkt', date: 'Sep 5, 2026', status: 'Draft' }, { id: '5', name: 'Wine Tasting', date: 'Sep 12, 2026', status: 'On sale' },];const statusVariants = { 'On sale': 'success', Paused: 'warning', Draft: 'info',} as const;export default () => { const [events, setEvents] = useState(initialEvents); const [selected, setSelected] = useState<Selection>(new Set()); const { addToast } = useToast(); const applyStatus = (status: Event['status']) => { // Resolve the selection to a concrete list once, so the count in the // toast and the records we change can never disagree. // [!code highlight:2] const keys = selected === 'all' ? events.map(event => event.id) : [...selected]; setEvents(current => current.map(event => keys.includes(event.id) ? { ...event, status } : event ) ); const scope = keys.length === 1 ? '1 event' : `${keys.length} events`; addToast({ title: `${scope} ${status === 'On sale' ? 'put on sale' : 'paused'}`, variant: 'success', }); setSelected(new Set()); // [!code highlight] }; return ( <> {/* The one toast region for this page: the other bulk-actions demos enqueue into the same global queue and render through it. */} <ToastProvider position="bottom-right" /> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> <Button onPress={() => applyStatus('On sale')}> <CircleCheck /> Put on sale </Button> <Button onPress={() => applyStatus('Paused')}> <CirclePause /> Pause sale </Button> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Date</Table.Column> <Table.Column>Status</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.date}</Table.Cell> <Table.Cell> <Badge variant={statusVariants[event.status]}> {event.status} </Badge> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> </> );};Confirming destructive actions
Destructive bulk actions multiply the cost of a mistake by the size of the selection, and without an undo system a confirmation dialog is the only safety net. Every destructive bulk action goes through one, using the useConfirmation hook from <Dialog>.
A bare "Are you sure?" protects nobody. The dialog must restate what is about to happen with the exact count and a summary of the impact, so the user can catch a wrong selection at the last moment:
Delete 12 events? This will cancel 47 ticket reservations and notify 138 customers.
Repeat the action verb and the count in the confirm button, "Delete 12 events" rather than "OK", so the button reads as the decision it is. Focus the cancel button when the dialog opens, so pressing Enter out of habit chooses the safe path.
Event | Date | Reservations | |
|---|---|---|---|
Jazz Night | Aug 12, 2026 | 47 | |
Open Air Kino | Aug 15, 2026 | 112 | |
Poetry Slam | Aug 21, 2026 | 0 | |
Herbstmarkt | Sep 5, 2026 | 23 | |
Wine Tasting | Sep 12, 2026 | 65 |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Button, Inline, Stack, Table, useConfirmation, useToast,} from '@marigold/components';import { RotateCcw, Trash2 } from '@marigold/icons';interface Event { id: string; name: string; date: string; reservations: number;}const initialEvents: Event[] = [ { id: '1', name: 'Jazz Night', date: 'Aug 12, 2026', reservations: 47 }, { id: '2', name: 'Open Air Kino', date: 'Aug 15, 2026', reservations: 112 }, { id: '3', name: 'Poetry Slam', date: 'Aug 21, 2026', reservations: 0 }, { id: '4', name: 'Herbstmarkt', date: 'Sep 5, 2026', reservations: 23 }, { id: '5', name: 'Wine Tasting', date: 'Sep 12, 2026', reservations: 65 },];export default () => { const [events, setEvents] = useState(initialEvents); const [selected, setSelected] = useState<Selection>(new Set()); const confirm = useConfirmation(); const { addToast } = useToast(); const deleteSelected = async () => { const keys = selected === 'all' ? events.map(event => event.id) : [...selected]; const affected = events.filter(event => keys.includes(event.id)); const reservations = affected.reduce( (sum, event) => sum + event.reservations, 0 ); const scope = affected.length === 1 ? '1 event' : `${affected.length} events`; // Name the exact count and the impact, repeat the verb in the confirm // button, and focus Cancel so a reflexive Enter takes the safe path. // [!code highlight:8] const result = await confirm({ variant: 'destructive', title: `Delete ${scope}?`, content: `This will cancel ${reservations} ticket reservations and notify the ticket buyers. This action cannot be undone.`, confirmationLabel: `Delete ${scope}`, cancelLabel: 'Cancel', autoFocusButton: 'cancel', }); if (result === 'confirmed') { setEvents(current => current.filter(event => !keys.includes(event.id))); setSelected(new Set()); addToast({ title: `${scope} deleted`, variant: 'success', }); } }; return ( <Stack space={2}> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> <Button onPress={deleteSelected}> <Trash2 /> Delete </Button> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Date</Table.Column> <Table.Column>Reservations</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.date}</Table.Cell> <Table.Cell>{event.reservations}</Table.Cell> </Table.Row> ))} </Table.Body> </Table> {events.length < initialEvents.length && ( <Inline alignX="right"> <Button variant="ghost" size="small" onPress={() => setEvents(initialEvents)} > <RotateCcw /> Reset demo </Button> </Inline> )} </Stack> );};Do
Confirm destructive bulk actions with the exact count and impact, and repeat the verb in the confirm button.
Don't
Don't confirm routine, recoverable actions. Overused dialogs train users to click through the one that matters.
Editing multiple fields
Some bulk operations need input before they can run, such as moving many events to a new date and venue. A single verb cannot carry that, so the action opens a <Drawer> with a small form that applies to every selected record on submit. The drawer keeps the table and the selected rows visible behind it, so users can verify the selection while they edit. The drawer pattern covers this use case from the drawer's perspective.
The hard problem in a bulk-edit form is that the selected records rarely agree. Three events may share a venue but differ in price. Be honest about it:
- Never pre-fill a field with one record's value when the values differ. A form that shows the first record's price invites the user to unknowingly overwrite the other eleven.
- Show mixed values explicitly. Leave the field empty with a "Multiple values" placeholder, a convention long established in professional editing tools. When all records agree, pre-filling the shared value is safe and helpful.
- Apply only what the user touched. Untouched fields keep each record's original value. Filling in a field is the opt-in.
- Gate high-stakes fields behind an explicit opt-in. For a field where an accidental overwrite is expensive, such as prices, require a checkbox to enable editing at all. The extra step is the point.
- Show what will change before it happens. Indicate the edited fields, and label the submit button with the scope: "Apply to 12 events". Inside the drawer the action bar's count is out of sight, so the button restates it at the moment of commit.
Event | Venue | Category | Price | |
|---|---|---|---|---|
Jazz Night | Jazzhaus | Concert | 24.00 € | |
Blues Session | Vorderhaus | Concert | 19.00 € | |
Swing Evening | Alte Wache | Concert | 24.00 € | |
Wine Tasting | Alte Wache | Culinary | 45.00 € |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Button, Checkbox, Drawer, NumberField, Select, Stack, Table, Text, useToast,} from '@marigold/components';import { Pencil } from '@marigold/icons';interface Event { id: string; name: string; venue: string; category: string; price: number;}const initialEvents: Event[] = [ { id: '1', name: 'Jazz Night', venue: 'Jazzhaus', category: 'Concert', price: 24, }, { id: '2', name: 'Blues Session', venue: 'Vorderhaus', category: 'Concert', price: 19, }, { id: '3', name: 'Swing Evening', venue: 'Alte Wache', category: 'Concert', price: 24, }, { id: '4', name: 'Wine Tasting', venue: 'Alte Wache', category: 'Culinary', price: 45, },];/** The value shared by all records, or `undefined` when values differ. */// [!code highlight:4]const sharedValue = <K extends keyof Event>(records: Event[], field: K) => records.every(record => record[field] === records[0]?.[field]) ? records[0]?.[field] : undefined;const BulkEditForm = ({ affected, onApply,}: { affected: Event[]; onApply: (changes: Partial<Event>) => void;}) => { const sharedVenue = sharedValue(affected, 'venue'); const sharedCategory = sharedValue(affected, 'category'); const sharedPrice = sharedValue(affected, 'price'); const [venue, setVenue] = useState(sharedVenue ?? null); const [category, setCategory] = useState(sharedCategory ?? null); const [editPrice, setEditPrice] = useState(false); const [price, setPrice] = useState(sharedPrice ?? NaN); // [!code highlight:7] // Implicit opt-in: a field is only applied when the user changed it. const changes: Partial<Event> = {}; if (venue !== (sharedVenue ?? null) && venue) changes.venue = String(venue); if (category !== (sharedCategory ?? null) && category) changes.category = String(category); // Explicit opt-in: price only applies while its checkbox is checked. if (editPrice && !Number.isNaN(price)) changes.price = price; const changedFields = Object.keys(changes); const scope = affected.length === 1 ? '1 event' : `${affected.length} events`; return ( <> <Drawer.Title>Edit {scope}</Drawer.Title> <Drawer.Content> <Stack space={4}> <Select label="Venue" placeholder="Multiple values" value={venue} onChange={key => setVenue(String(key))} > <Select.Option id="Jazzhaus">Jazzhaus</Select.Option> <Select.Option id="Vorderhaus">Vorderhaus</Select.Option> <Select.Option id="Alte Wache">Alte Wache</Select.Option> </Select> <Select label="Category" placeholder="Multiple values" value={category} onChange={key => setCategory(String(key))} > <Select.Option id="Concert">Concert</Select.Option> <Select.Option id="Culinary">Culinary</Select.Option> <Select.Option id="Theater">Theater</Select.Option> </Select> <Stack space={2}> <Checkbox label="Change ticket price" checked={editPrice} onChange={setEditPrice} /> <NumberField label="Ticket price" disabled={!editPrice} value={price} onChange={setPrice} description={ sharedPrice === undefined ? 'Selected events currently have different prices.' : undefined } formatOptions={{ style: 'currency', currency: 'EUR' }} /> </Stack> <Text fontSize="sm" variant="muted" aria-live="polite"> {changedFields.length === 0 ? 'No changes yet. Untouched fields stay as they are.' : `Will update ${changedFields.join(' and ')} on all selected events.`} </Text> </Stack> </Drawer.Content> <Drawer.Actions> <Button slot="close">Cancel</Button> <Button slot="close" variant="primary" disabled={changedFields.length === 0} onPress={() => onApply(changes)} > Apply to {scope} </Button> </Drawer.Actions> </> );};export default () => { const [events, setEvents] = useState(initialEvents); const [selected, setSelected] = useState<Selection>(new Set(['1', '2', '3'])); const { addToast } = useToast(); const selectedIds = selected === 'all' ? events.map(event => event.id) : [...selected].map(String); const affected = events.filter(event => selectedIds.includes(event.id)); const applyChanges = (changes: Partial<Event>) => { setEvents(current => current.map(event => selectedIds.includes(event.id) ? { ...event, ...changes } : event ) ); addToast({ title: affected.length === 1 ? '1 event updated' : `${affected.length} events updated`, variant: 'success', }); setSelected(new Set()); }; return ( <> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> <Drawer.Trigger> <Button> <Pencil /> Edit </Button> <Drawer size="medium"> {/* Re-key the form so it derives fresh values per selection. */} <BulkEditForm key={selectedIds.join('-')} // [!code highlight] affected={affected} onApply={applyChanges} /> </Drawer> </Drawer.Trigger> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Venue</Table.Column> <Table.Column>Category</Table.Column> <Table.Column>Price</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.venue}</Table.Cell> <Table.Cell>{event.category}</Table.Cell> <Table.Cell>{event.price.toFixed(2)} €</Table.Cell> </Table.Row> ))} </Table.Body> </Table> </> );};A destructive operation configured in a drawer still needs its confirm dialog after the submit, count and impact included. The drawer collects the input but does not replace the confirmation.
Showing progress
Bulk operations in Reservix products run synchronously: the user starts the operation, watches it complete, and sees the result in the same session. There is no background-job or notification pattern to design for, which keeps the feedback model simple and tied to how long the operation takes:
- Under about two seconds, feedback on the pressed control is enough: set the
<Button>loadingprop. - Between two and ten seconds, show progress with a running count where the actions were, "Sending 4 of 12", so the user sees the operation moving through the selection. Keep the bar visible and the selection intact while it runs.
- Beyond ten seconds, users lose attention and need a way to interrupt. No current Reservix bulk operation crosses this line. If yours does, the operation needs product-level rework rather than a bigger spinner.
Disable the other actions in the bar while an operation runs, so two bulk operations cannot race on the same selection. For the general feedback vocabulary of spinners and skeletons, see the Loading States pattern.
Event | Tickets sold | |
|---|---|---|
Jazz Night | 214 | |
Open Air Kino | 460 | |
Poetry Slam | 95 | |
Herbstmarkt | 310 | |
Wine Tasting | 58 | |
Klassik im Park | 127 |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Button, Inline, ProgressCircle, Table, Text, useToast,} from '@marigold/components';import { Download, Send } from '@marigold/icons';const events = [ { id: '1', name: 'Jazz Night', tickets: 214 }, { id: '2', name: 'Open Air Kino', tickets: 460 }, { id: '3', name: 'Poetry Slam', tickets: 95 }, { id: '4', name: 'Herbstmarkt', tickets: 310 }, { id: '5', name: 'Wine Tasting', tickets: 58 }, { id: '6', name: 'Klassik im Park', tickets: 127 },];const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));export default () => { const [selected, setSelected] = useState<Selection>( new Set(events.map(event => event.id)) ); const [exporting, setExporting] = useState(false); const [sending, setSending] = useState<{ done: number; total: number }>(); const { addToast } = useToast(); const count = selected === 'all' ? events.length : selected.size; // A quick operation: the pressed button carries the feedback. const exportSelected = async () => { setExporting(true); await wait(1500); setExporting(false); addToast({ title: count === 1 ? '1 event exported' : `${count} events exported`, variant: 'success', }); setSelected(new Set()); }; // A longer, per-record operation: the bar reports the running count. const sendReminders = async () => { for (let done = 0; done < count; done++) { setSending({ done, total: count }); await wait(600); } setSending(undefined); addToast({ title: count === 1 ? '1 reminder sent' : `${count} reminders sent`, variant: 'success', }); setSelected(new Set()); }; return ( <> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> {sending ? ( // Longer op: the running count replaces the actions in place, // announced politely so it is not just a visual change. // [!code highlight:6] <Inline space={2} alignY="center"> <ProgressCircle /> <Text aria-live="polite"> Sending {sending.done + 1} of {sending.total}… </Text> </Inline> ) : ( <> {/* Quick op: feedback rides on the pressed button. */} {/* [!code highlight:4] */} <Button loading={exporting} onPress={exportSelected}> <Download /> Export </Button> {/* Disable siblings so two operations can't race. */} <Button disabled={exporting} onPress={sendReminders}> <Send /> Send reminder </Button> </> )} </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Tickets sold</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell>{event.tickets}</Table.Cell> </Table.Row> ))} </Table.Body> </Table> </> );};Result feedback
Every bulk operation ends with an explicit result. Success is a <Toast> that names the count, "12 events updated", and the selection clears because the job is done.
Partial failure is the case that separates a robust implementation from a fragile one. When some records succeed and some fail, never report a bare error. Summarize the outcome with exact numbers, name the reason where it is known, and keep the failed records selected so the user can fix the cause and retry without hunting for them:
11 of 12 events updated. 1 failed: missing description. It remains selected for retry.
The succeeded records leave the selection, the failed ones stay. The selection itself becomes the retry list, which is more durable than a toast that disappears.
Staleness is a failure like any other. When a colleague edits or removes a record between selection and execution, the server skips it and the report names it like any other failure: "2 events were changed by someone else and were skipped." What the user selected and what the operation touched must never quietly drift apart.
Event | Description | Status | |
|---|---|---|---|
Jazz Night | Complete | Draft | |
Open Air Kino | Missing | Draft | |
Poetry Slam | Complete | Draft | |
Herbstmarkt | Missing | Draft | |
Wine Tasting | Complete | Draft |
import { useState } from 'react';import type { Selection } from '@marigold/components';import { ActionBar, Badge, Button, Table, Text, useToast,} from '@marigold/components';import { CircleCheck } from '@marigold/icons';interface Event { id: string; name: string; status: 'Draft' | 'On sale'; /** Events without a description cannot be published. */ hasDescription: boolean;}const initialEvents: Event[] = [ { id: '1', name: 'Jazz Night', status: 'Draft', hasDescription: true }, { id: '2', name: 'Open Air Kino', status: 'Draft', hasDescription: false }, { id: '3', name: 'Poetry Slam', status: 'Draft', hasDescription: true }, { id: '4', name: 'Herbstmarkt', status: 'Draft', hasDescription: false }, { id: '5', name: 'Wine Tasting', status: 'Draft', hasDescription: true },];export default () => { const [events, setEvents] = useState(initialEvents); const [selected, setSelected] = useState<Selection>( new Set(initialEvents.map(event => event.id)) ); const { addToast } = useToast(); const publishSelected = () => { const keys = selected === 'all' ? events.map(event => event.id) : [...selected].map(String); const affected = events.filter(event => keys.includes(event.id)); const succeeded = affected.filter(event => event.hasDescription); const failed = affected.filter(event => !event.hasDescription); setEvents(current => current.map(event => succeeded.some(published => published.id === event.id) ? { ...event, status: 'On sale' } : event ) ); if (failed.length === 0) { addToast({ title: succeeded.length === 1 ? '1 event published' : `${succeeded.length} events published`, variant: 'success', }); setSelected(new Set()); return; } addToast({ title: `${succeeded.length} of ${affected.length} events published`, description: `${failed.length} failed: missing description. ${failed.length === 1 ? 'It remains' : 'They remain'} selected for retry.`, variant: 'warning', }); // The selection becomes the retry list: drop what succeeded, keep // what failed, so the next press acts on exactly the leftovers. setSelected(new Set(failed.map(event => event.id))); // [!code highlight] }; return ( <> <Table aria-label="Events" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} actionBar={() => ( <ActionBar> <Button onPress={publishSelected}> <CircleCheck /> Publish </Button> </ActionBar> )} > <Table.Header> <Table.Column rowHeader>Event</Table.Column> <Table.Column>Description</Table.Column> <Table.Column>Status</Table.Column> </Table.Header> <Table.Body> {events.map(event => ( <Table.Row key={event.id} id={event.id}> <Table.Cell>{event.name}</Table.Cell> <Table.Cell> {event.hasDescription ? ( 'Complete' ) : ( <Text variant="muted">Missing</Text> )} </Table.Cell> <Table.Cell> <Badge variant={event.status === 'On sale' ? 'success' : 'info'} > {event.status} </Badge> </Table.Cell> </Table.Row> ))} </Table.Body> </Table> </> );};Accessibility
The <Table> selection column and the <ActionBar> already cover the announcements and keyboard behavior a bulk-selection pattern is expected to have. The remaining work sits in how you compose the flow:
- Give every icon-only action a text label, visible or via
aria-label, so each action is announced clearly. - Carry the count into the result toast. The toast text is what assistive technology announces, so the same precision reaches everyone.
- Announce running progress. Render the "4 of 12" progress text in a live region (
aria-live="polite") so screen reader users hear the operation advance. - Keep focus on the safe path in confirm dialogs. Focus the cancel button by default for destructive actions so Enter does not confirm by accident.
Implementation
The demos above apply a few practices that are easy to miss but make the difference between a bulk flow that feels dependable and one that surprises users.
Resolve the selection to records once
The Table's actionBar render prop and onSelectionChange hand you a Selection, which is either a Set of keys or the string 'all' when the select-all checkbox is used. Resolve it to a concrete list of records in one place before acting, so counts, confirmation messages, and the operation itself always agree:
const keys = selected === 'all' ? events.map(event => event.id) : [...selected];
const affected = events.filter(event => keys.includes(event.id));Every message the user sees derives from affected.length, never from a separately tracked counter that can drift. Resolving this way also protects against two subtle mistakes. The selection is a set of record ids, not row positions, so a table that re-sorts or refreshes between selecting and pressing cannot shift the selection onto different rows. The server also receives this exact list of ids, never an instruction to run the current filter again. A query that runs again would quietly include records that only started matching after the user made their selection.
Clear selection when the scope changes
Pagination, page size, sorting, filters, and search all change which rows are visible, and the selection must never outlive the view it was made in. Wrap the state setters so the reset cannot be forgotten at individual call sites:
const changeFilter = (next: string) => {
setSelected(new Set());
setStatus(next);
};Keep failed records selected
After a partial failure, rebuild the selection from the failures. The selection becomes the retry list:
if (failed.length > 0) {
setSelected(new Set(failed.map(event => event.id)));
}Step back when a page empties
A destructive bulk action can remove every row of the current page in a single press. When that happens the page index points past the end of the data, and a naive implementation strands the user on an empty page, sometimes without pagination controls to escape it. Clamp the page after the records are gone:
const lastPage = Math.max(1, Math.ceil(remaining.length / pageSize));
if (page > lastPage) {
setPage(lastPage);
}Re-key the bulk-edit form
The bulk-edit form derives its initial values, shared or mixed, from the selected records when it mounts. Key the form with the selection so a changed selection produces a freshly derived form instead of stale fields:
<Drawer size="medium">
<BulkEditForm key={selectedIds.join('-')} affected={affected} />
</Drawer>