Bulk Actions

Orders

OrderCustomerDateTotalStatus
#10482Anna Smith$186.84Unfulfilled
#10481Ben King$89.00Unfulfilled
#10480Lea Moreau$342.10Fulfilled
#10479Jon Diaz$54.50Unfulfilled
#10478Mia Chen$129.00Refunded
@use 'sprucecss/scss/spruce' as *;

.bulk-actions {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  overflow: hidden;

  &__header {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs') spacer('m');
    justify-content: space-between;
    min-block-size: 3.75rem;
    padding: spacer('xs') spacer('m');
  }

  &__title {
    font-size: font-size('h5');
    margin: 0;
  }

  &__bar {
    @include layout-cluster('xs');
    border-radius: radius('md');
    margin-inline-start: auto;
    @include transition($property: 'background-color, padding');
  }

  &__count {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    margin: 0;

    .bulk-actions__bar--active & {
      color: color('heading');
      font-weight: 700;
      margin-inline-end: spacer('xs');
    }

    &:empty {
      display: none;
    }
  }

  &__table {
    margin: 0;

    tr:has(.form-check__control:checked) {
      background-color: color-alpha('primary', 0.03);
    }

    th[scope='row'] a {
      font-weight: 700;
      text-decoration: none;

      &:hover {
        text-decoration: underline;
      }
    }
  }

  &__check {
    inline-size: 3.5rem;
    min-inline-size: 0 !important;
    padding-inline-start: spacer('m') !important;

    .form-check__control {
      display: block;
    }
  }

  &__number {
    font-variant-numeric: tabular-nums;
    text-align: end;
  }

  &__status {
    align-items: center;
    display: inline-flex;
    font-size: config('font-size-sm', $typography);
    gap: spacer('xxs');
    white-space: nowrap;

    &::before {
      aspect-ratio: 1;
      background-color: color('muted');
      border-radius: 50%;
      content: '';
      inline-size: 0.5rem;
    }

    &--success::before {
      background-color: color('success', 'alert');
    }

    &--warning::before {
      background-color: color('warning', 'alert');
    }
  }
}

[dir='rtl'] .bulk-actions__bar--active {
  padding: spacer('xxs') spacer('s') spacer('xxs') spacer('xxs');
}
<form class="bulk-actions" action="#" method="post" data-bulk-actions>
    <div class="bulk-actions__header">
        <h3 class="bulk-actions__title" id="bulk-actions-title">Orders</h3>
        <div class="bulk-actions__bar" role="toolbar" aria-label="Bulk actions" data-bulk-bar>
            <p class="bulk-actions__count" data-bulk-count>Select orders to apply an action</p>
            <button class="btn btn--sm btn--outline-secondary" type="submit" name="action" value="fulfill">Mark as fulfilled</button>
            <button class="btn btn--sm btn--outline-secondary" type="submit" name="action" value="export">Export</button>
            <button class="btn btn--sm btn--outline-danger" type="submit" name="action" value="archive">Archive</button>
            <button class="btn btn--sm btn--ghost-secondary" type="button" data-bulk-clear hidden>Clear</button>
        </div>
    </div>
    <p class="sr-only" aria-live="polite" data-bulk-live></p>
    <div class="table-responsive">
        <table class="table table--hover table--middle bulk-actions__table" aria-labelledby="bulk-actions-title">
            <thead>
                <tr>
                    <th class="bulk-actions__check" scope="col"><span class="form-check"><input class="form-check__control" type="checkbox" aria-label="Select all orders" data-bulk-all hidden></span></th>
                    <th scope="col">Order</th>
                    <th scope="col">Customer</th>
                    <th scope="col">Date</th>
                    <th class="bulk-actions__number" scope="col">Total</th>
                    <th scope="col">Status</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td class="bulk-actions__check"><span class="form-check"><input class="form-check__control" type="checkbox" name="orders[]" value="10482" aria-label="Select order #10482" data-bulk-item></span></td>
                    <th scope="row"><a href="#">#10482</a></th>
                    <td>Anna Smith</td>
                    <td><time datetime="2026-10-03">Oct 3</time></td>
                    <td class="bulk-actions__number">$186.84</td>
                    <td><span class="bulk-actions__status bulk-actions__status--warning">Unfulfilled</span></td>
                </tr>
                <tr>
                    <td class="bulk-actions__check"><span class="form-check"><input class="form-check__control" type="checkbox" name="orders[]" value="10481" aria-label="Select order #10481" data-bulk-item></span></td>
                    <th scope="row"><a href="#">#10481</a></th>
                    <td>Ben King</td>
                    <td><time datetime="2026-10-03">Oct 3</time></td>
                    <td class="bulk-actions__number">$89.00</td>
                    <td><span class="bulk-actions__status bulk-actions__status--warning">Unfulfilled</span></td>
                </tr>
                <tr>
                    <td class="bulk-actions__check"><span class="form-check"><input class="form-check__control" type="checkbox" name="orders[]" value="10480" aria-label="Select order #10480" data-bulk-item></span></td>
                    <th scope="row"><a href="#">#10480</a></th>
                    <td>Lea Moreau</td>
                    <td><time datetime="2026-10-02">Oct 2</time></td>
                    <td class="bulk-actions__number">$342.10</td>
                    <td><span class="bulk-actions__status bulk-actions__status--success">Fulfilled</span></td>
                </tr>
                <tr>
                    <td class="bulk-actions__check"><span class="form-check"><input class="form-check__control" type="checkbox" name="orders[]" value="10479" aria-label="Select order #10479" data-bulk-item></span></td>
                    <th scope="row"><a href="#">#10479</a></th>
                    <td>Jon Diaz</td>
                    <td><time datetime="2026-10-02">Oct 2</time></td>
                    <td class="bulk-actions__number">$54.50</td>
                    <td><span class="bulk-actions__status bulk-actions__status--warning">Unfulfilled</span></td>
                </tr>
                <tr>
                    <td class="bulk-actions__check"><span class="form-check"><input class="form-check__control" type="checkbox" name="orders[]" value="10478" aria-label="Select order #10478" data-bulk-item></span></td>
                    <th scope="row"><a href="#">#10478</a></th>
                    <td>Mia Chen</td>
                    <td><time datetime="2026-10-01">Oct 1</time></td>
                    <td class="bulk-actions__number">$129.00</td>
                    <td><span class="bulk-actions__status bulk-actions__status--muted">Refunded</span></td>
                </tr>
            </tbody>
        </table>
    </div>
</form>
(() => {
  document.querySelectorAll('[data-bulk-actions]').forEach((form) => {
    const all = form.querySelector('[data-bulk-all]');
    const items = [...form.querySelectorAll('[data-bulk-item]')];
    const bar = form.querySelector('[data-bulk-bar]');
    const count = form.querySelector('[data-bulk-count]');
    const clear = form.querySelector('[data-bulk-clear]');
    const live = form.querySelector('[data-bulk-live]');
    const actions = [...bar.querySelectorAll('button[type="submit"]')];
    let last = null;

    const update = (announce = true) => {
      const selected = items.filter((item) => item.checked).length;
      const text = selected ? `${selected} of ${items.length} selected` : 'No orders selected';

      all.checked = selected === items.length;
      all.indeterminate = selected > 0 && selected < items.length;
      count.textContent = selected ? text : '';
      bar.classList.toggle('bulk-actions__bar--active', selected > 0);
      actions.forEach((button) => { button.hidden = !selected; });
      clear.hidden = !selected;

      if (announce) {
        live.textContent = text;
      }
    };

    // The select-all checkbox needs JavaScript, so it is revealed here.
    all.hidden = false;

    all.addEventListener('change', () => {
      items.forEach((item) => { item.checked = all.checked; });
      update();
    });

    // Shift + click selects the range from the last clicked checkbox.
    items.forEach((item) => {
      item.addEventListener('click', (event) => {
        if (event.shiftKey && last) {
          const [start, end] = [items.indexOf(last), items.indexOf(item)].sort((a, b) => a - b);
          items.slice(start, end + 1).forEach((other) => { other.checked = item.checked; });
        }

        last = item;
        update();
      });
    });

    clear.addEventListener('click', () => {
      items.forEach((item) => { item.checked = false; });
      update();
      all.focus();
    });

    update(false);
  });
})();

Bulk Actions

A selectable table with a "select all" checkbox and a toolbar of actions that appears for the selected rows.

Anatomy

  • .bulk-actions: a form around the toolbar and the table, so the checked rows and the clicked action are submitted together (orders[] and action).
  • .bulk-actions__bar: a role="toolbar" with the selection count and the action buttons; it gets the --active modifier when something is selected.
  • .bulk-actions__table: a Spruce .table; the selected rows are highlighted with tr:has(:checked), without JavaScript.

Accessibility and Usability

  • Every row checkbox has a specific label ("Select order #10482"), not a generic "Select".
  • The select-all checkbox becomes indeterminate when only some rows are selected, which screen readers announce as "mixed".
  • A polite live region announces the selection ("3 of 5 selected"). After "Clear", the focus moves to the select-all checkbox, so it isn't lost.
  • Shift + click selects a range, like in file managers and mail clients.
  • Use a confirmation (a Modal) for destructive actions like "Archive" or "Delete".

Technical Details

  • Progressive enhancement: without JavaScript, the action buttons are always visible and the form still submits the checked rows. The script reveals the select-all checkbox and the "Clear" button and shows the actions only when there is a selection.
  • Combine it with the Data Table for sorting and filtering.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.