Marigold
v18.0.0-beta.3
Marigold
v18.0.0-beta.3

Layout

App Framealpha
Admin- and Mastermarkbeta

User Input

Bulk Actionsbeta
Filteralpha
Formsbeta
Multiple Selection
Table Recordsalpha

Feedback

Async Data Loading
Feedback Messages
Loading States

Data

Fetching and Mutationsalpha
Patterns

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 AllSelectionAction BarResult ToastConfirm Dialog
  • 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:

StageThe user...Built with
Selectionpicks the records to act on<Table> with selectionMode="multiple"
Action choicepicks an operation for the selection<ActionBar> via the Table's actionBar prop
Configurationfills in values for a multi-field edit<Drawer> opened from an action
Confirmationapproves a destructive operation<Dialog> via the useConfirmation hook
Executionwaits for the operation to finish<Button> loading or an inline <ProgressCircle>
Resultlearns 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.

Status
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> loading prop.
  • 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>

Demo

View DemoOpen the interactive exampleView CodeBrowse the source on GitHub

Related

ActionBar

The floating toolbar that carries the bulk actions for the current selection.

Table Records

Creating, correcting, and maintaining individual records in data heavy tables.

Filter

Narrow the visible set before selecting, and understand why scope changes clear the selection.
Last update: 4 days ago

Admin- and Mastermark

Marking internal-only features.

Filter

Understand methods to refine data.

On this page

When to useWhen not to useStructureChoosing actionsSelectionScopePaginationDirect actionsConfirming destructive actionsEditing multiple fieldsShowing progressResult feedbackAccessibilityImplementationResolve the selection to records onceClear selection when the scope changesKeep failed records selectedStep back when a page emptiesRe-key the bulk-edit formDemoRelated