Forms
Batch action bar
A bar that exists only while something is selected: it states the count in words, caps at five actions, and switches off the per-row controls beneath it so there is one live scope at a time.
Reach for it when building
- selecting photos in a library
- bulk tagging a media collection
- file browser multi-select
- moving tracks between playlists
- clearing items from a download queue
- batch operations on a table
- selection
- bulk-actions
- toolbar
- checkbox
- indeterminate
<div class="bab-demo">
<div class="bab-head">
<label class="bab-all"><input type="checkbox" id="bab-all"> Select all</label>
</div>
<div class="bab-bar" id="bab-bar" role="toolbar" aria-label="Actions for the selected photos" hidden>
<span class="bab-count" id="bab-count"></span>
<button type="button" class="bab-act" data-act="Added to album">Add to album</button>
<button type="button" class="bab-act" data-act="Tagged">Tag</button>
<button type="button" class="bab-act" data-act="Downloaded">Download</button>
<button type="button" class="bab-act" data-act="Deleted">Delete</button>
<button type="button" class="bab-act bab-cancel" id="bab-cancel">Cancel</button>
</div>
<ul class="bab-list" id="bab-list"></ul>
<p class="bab-log" id="bab-log" role="status"></p>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.bab-demo { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
.bab-head { display: flex; align-items: center; gap: 0.6rem; font-size: 0.875rem; color: var(--dim); }
.bab-all { display: inline-flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.bab-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.bab-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border);
}
.bab-row:last-child { border-bottom: none; }
.bab-row.is-picked { background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
.bab-name { flex: 1; cursor: pointer; }
.bab-size { font-family: var(--mono); font-size: 0.8125rem; color: var(--dim); }
.bab-row-btn {
font: inherit;
font-size: 0.8125rem;
background: none;
border: 1px solid var(--border);
color: var(--dim);
border-radius: 6px;
padding: 0.2rem 0.55rem;
cursor: pointer;
}
/* Switched off while the bar is up: two live scopes over the same rows is
the ambiguity this pattern exists to remove. */
.bab-row-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.bab-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
}
.bab-bar[hidden] { display: none; }
.bab-count { font-weight: 600; font-size: 0.9375rem; margin-right: auto; }
.bab-act {
font: inherit;
font-size: 0.8125rem;
background: color-mix(in srgb, var(--accent-ink) 18%, transparent);
color: var(--accent-ink);
border: 1px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.bab-act:hover { background: color-mix(in srgb, var(--accent-ink) 30%, transparent); }
.bab-cancel { border-style: dashed; }
.bab-log { margin: 0; font-size: 0.875rem; color: var(--dim); min-height: 1.4em; }var photos = [
{ id: 'p1', name: 'harbour-dawn.jpg', size: '4.2 MB' },
{ id: 'p2', name: 'kitchen-table.jpg', size: '3.1 MB' },
{ id: 'p3', name: 'ferry-window.jpg', size: '5.8 MB' },
{ id: 'p4', name: 'street-market.jpg', size: '2.9 MB' },
{ id: 'p5', name: 'rooftop-dusk.jpg', size: '6.4 MB' }
];
var picked = [];
var listElement = document.getElementById('bab-list');
var barElement = document.getElementById('bab-bar');
var countElement = document.getElementById('bab-count');
var logElement = document.getElementById('bab-log');
var selectAllElement = document.getElementById('bab-all');
var cancelElement = document.getElementById('bab-cancel');
function isPicked(id) {
return picked.indexOf(id) !== -1;
}
function sync() {
var count = picked.length;
barElement.hidden = count === 0;
countElement.textContent = count === 1 ? '1 photo selected' : count + ' photos selected';
// The header box has three states, not two: a partial selection is
// indeterminate, so clicking it is unambiguously "select the rest".
selectAllElement.checked = count === photos.length;
selectAllElement.indeterminate = count > 0 && count < photos.length;
var rows = listElement.querySelectorAll('.bab-row');
Array.prototype.forEach.call(rows, function (row) {
var rowIsPicked = isPicked(row.getAttribute('data-id'));
if (rowIsPicked) row.classList.add('is-picked');
else row.classList.remove('is-picked');
row.querySelector('.bab-row-btn').disabled = count > 0;
});
}
function render() {
listElement.innerHTML = '';
photos.forEach(function (photo) {
var row = document.createElement('li');
row.className = 'bab-row';
row.setAttribute('data-id', photo.id);
var box = document.createElement('input');
box.type = 'checkbox';
box.id = 'bab-' + photo.id;
box.checked = isPicked(photo.id);
box.addEventListener('change', function () {
if (box.checked) picked.push(photo.id);
else picked = picked.filter(function (id) { return id !== photo.id; });
logElement.textContent = '';
sync();
});
var label = document.createElement('label');
label.className = 'bab-name';
label.setAttribute('for', box.id);
label.textContent = photo.name;
var size = document.createElement('span');
size.className = 'bab-size';
size.textContent = photo.size;
var rename = document.createElement('button');
rename.type = 'button';
rename.className = 'bab-row-btn';
rename.textContent = 'Rename';
row.appendChild(box);
row.appendChild(label);
row.appendChild(size);
row.appendChild(rename);
listElement.appendChild(row);
});
sync();
}
function clearSelection() {
picked = [];
var boxes = listElement.querySelectorAll('input[type=checkbox]');
Array.prototype.forEach.call(boxes, function (box) { box.checked = false; });
sync();
}
selectAllElement.addEventListener('change', function () {
picked = selectAllElement.checked ? photos.map(function (photo) { return photo.id; }) : [];
var boxes = listElement.querySelectorAll('input[type=checkbox]');
Array.prototype.forEach.call(boxes, function (box, index) {
box.checked = isPicked(photos[index].id);
});
logElement.textContent = '';
sync();
});
// Cancel clears the selection and does nothing else — it is not an undo,
// and it must never be mistaken for one.
cancelElement.addEventListener('click', function () {
clearSelection();
logElement.textContent = 'Selection cleared. Nothing was changed.';
});
var actionButtons = barElement.querySelectorAll('.bab-act[data-act]');
Array.prototype.forEach.call(actionButtons, function (button) {
button.addEventListener('click', function () {
var count = picked.length;
logElement.textContent =
button.getAttribute('data-act') + ' ' + count + (count === 1 ? ' photo.' : ' photos.');
clearSelection();
});
});
render();Paste this into an agent to rebuild the pattern from scratch.
Build a bar that appears when a selection exists and offers the actions that apply to it. Nothing appears when nothing is selected.
Reach for this when a list supports picking more than one thing and the same handful of actions apply to any pick — a photo library, a file browser, a queue. Walk away when only one action exists, because that belongs on the row; when the available actions differ per row, because then the bar cannot honestly say what it does; and when what you actually want is bulk *editing* of field values, which is a different surface with different problems.
**The bar exists only with a selection.** Do not ship it permanently with greyed-out buttons. A disabled toolbar is permanent furniture that teaches nothing and takes space on every screen, and it makes the moment a selection begins invisible. Appearing is the feedback.
**Say the count, in the bar, in words.** "3 photos selected", not a bare numeral and not nothing. This is the only place someone can confirm the scope of what they are about to do, and it matters most in the case that goes wrong: a filter changed underneath a selection and it is now larger or smaller than assumed.
**Cap the bar at five actions.** Past five nobody reads them, and the sixth is a sign the list is doing too many jobs. If more are genuinely needed, the extras go in an overflow menu, not on the bar.
**Switch off row-level actions while the bar is up.** Two live scopes over the same rows — a bar acting on the selection and a button acting on one row — is exactly the ambiguity that makes people delete the wrong thing. One scope at a time; disable the per-row controls until the selection clears.
**Cancel clears the selection and does nothing else.** It is not an undo and must never read as one. If an action has already run, Cancel cannot take it back, so keep its label about the selection and pair the action itself with a separate undo if it needs one.
Give the select-all checkbox three states rather than two. A partial selection sets "indeterminate", which is the state that makes clicking it unambiguously mean "select the rest" instead of leaving someone guessing whether it will select all or clear everything. Set the property in script — there is no HTML attribute for it.
After an action runs, clear the selection. The rows it applied to have changed, and leaving them selected invites a second application to a set that no longer means what it did.
Every colour comes from theme custom properties. The bar uses the accent as its ground, so its own controls have to derive from the accent's contrast colour rather than from the page ink, or they vanish against it in one of the two themes.