Bulk actions toolbar
Select rows → sticky action bar with Mark / Delete / Copy as TSV. The Gmail / Linear pattern.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Selection & Clipboard). See the SvGrid documentation for the full API.
About this example
A hand-built bulk-actions toolbar for the Svelte 5 data grid. When rows are checked, a sticky bar slides in above the grid with Mark, Delete and Copy as TSV; Clear or unchecking the rows dismisses it. The wiring is onRowSelectionChange to learn which rows are selected, a toolbar that reads that array, and mutations written back to the rows state, with api.clearRowSelection to reset. It is the do-it-yourself version of the built-in selectionBar.
The Gmail / Linear / Asana pattern: when one or more rows is selected, a sticky action bar slides in above the grid offering bulk operations (mark, delete, export). Click "Clear" or uncheck rows to dismiss.
The wiring is small: onRowSelectionChange tells you which rows are selected; the toolbar reads that array and dispatches mutations back to the rows state.
This is the DIY version, and it is deliberately kept: the toolbar is your markup, in your layout, doing whatever you want. Demo 430 is the same idea built in - selectionBar floats a bar over the grid with a count chip, built-in Select all / Edit fields (a bulk-edit drawer), an overflow menu and top/bottom placement. Reach for 430 when you want it working out of the box; stay here when you want full control of the markup.
Imports, features and API used
Imports: @svgrid/grid, ../shared/seed
Table features registered: rowSortingFeature, columnFilteringFeature, rowSelectionFeature
Columns: company (Company), product (Product), country (Country), quantity (Qty), price (Price), inStock (In stock), sellDate (Sell date)
SvGridApi methods called: api.clearRowSelection()
Frequently asked questions
How do I know which rows are checked?
Pass onRowSelectionChange. It is called with the selection state and the selected row objects; the demo keeps those rows in $state and shows the toolbar whenever the array is non-empty.
How do I delete or update the selected rows?
Mutate your own rows array: filter out the selected ids for Delete, or map over it for Mark. The grid re-renders from the new array, then api.clearRowSelection() empties the checkboxes.
When should I use the built-in selectionBar instead?
When you want the bar working out of the box with a count chip, Select all, a bulk-edit drawer and an overflow menu. Stay with this pattern when the toolbar must be your own markup in your own layout.
Related documentation
Related articles
- Bulk Operations on Selected Rows in SvGrid - Wire row selection into a contextual action toolbar - bulk edit, delete, and export selected rows with the SvGrid selection API.
- Copy a Cell Range to the Clipboard in SvGrid - How SvGrid copies selected cell ranges as tab-separated values that paste cleanly into Excel and Google Sheets, plus headers, programmatic copy, and format control.
- Row Selection and Clipboard Copy-Paste in SvGrid - How checkbox row selection, cell range copy, and Excel-compatible clipboard output work together in SvGrid - with a full working example.
Source code (23-bulk-actions.svelte)
<!-- Documented in: docs/help/recipes.md -->
<script lang="ts">
/**
* 23. Bulk actions toolbar
* ------------------------
* The Gmail / Linear / Asana pattern: when one or more rows is selected,
* a sticky action bar slides in above the grid offering bulk operations
* (mark, delete, export). Click "Clear" or uncheck rows to dismiss.
*
* The wiring is small: onRowSelectionChange tells you which rows are
* selected; the toolbar reads that array and dispatches mutations back
* to the rows state.
*
* This is the DIY version, and it is deliberately kept: the toolbar is your
* markup, in your layout, doing whatever you want. Demo 430 is the same idea
* built in - `selectionBar` floats a bar over the grid with a count chip,
* built-in Select all / Edit fields (a bulk-edit drawer), an overflow menu
* and top/bottom placement. Reach for 430 when you want it working out of
* the box; stay here when you want full control of the markup.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
rowSelectionFeature,
type GridColumns,
type SvGridApi,
} from '@svgrid/grid'
import { makeOrders, type Order } from '../shared/seed'
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
rowSelectionFeature,
})
let rows = $state<Order[]>(makeOrders(120))
let selectedRows = $state<Order[]>([])
let api = $state<SvGridApi<typeof features, Order> | null>(null)
let toast = $state<string>('')
const columns: GridColumns<Order> = [
{ field: 'company', header: 'Company', width: 160 },
{ field: 'product', header: 'Product', width: 200 },
{ field: 'country', header: 'Country', width: 110 },
{ field: 'quantity', header: 'Qty', width: 80,
format: { type: 'number', options: { maximumFractionDigits: 0 } } },
{ field: 'price', header: 'Price', width: 110,
format: { type: 'currency', currency: 'USD' } },
{ field: 'inStock', header: 'In stock', width: 90 },
{ field: 'sellDate', header: 'Sell date', width: 110,
format: { type: 'date', pattern: 'y-m-d' } },
]
function flash(msg: string) {
toast = msg
setTimeout(() => { if (toast === msg) toast = '' }, 2200)
}
function markInStock() {
const ids = new Set(selectedRows.map((r) => r.id))
rows = rows.map((r) => (ids.has(r.id) ? { ...r, inStock: true } : r))
flash(`Marked ${selectedRows.length} order${selectedRows.length === 1 ? '' : 's'} in stock`)
clearSelection()
}
function deleteSelected() {
const ids = new Set(selectedRows.map((r) => r.id))
const n = selectedRows.length
rows = rows.filter((r) => !ids.has(r.id))
flash(`Deleted ${n} order${n === 1 ? '' : 's'}`)
clearSelection()
}
function exportSelectedTsv() {
const fields = columns.map((c) => c.field as keyof Order)
const header = columns.map((c) => c.header as string).join('\t')
const body = selectedRows
.map((r) => fields.map((f) => String(r[f] ?? '')).join('\t'))
.join('\n')
const tsv = header + '\n' + body
navigator.clipboard?.writeText(tsv).then(
() => flash(`Copied ${selectedRows.length} row${selectedRows.length === 1 ? '' : 's'} as TSV`),
() => flash('Clipboard blocked - see source for the export payload'),
)
}
function clearSelection() {
// Tell the grid to wipe its internal selection map - it will fire
// onRowSelectionChange({}, []) which resets `selectedRows`. We don't
// need to mutate it locally first.
api?.clearRowSelection()
}
const n = $derived(selectedRows.length)
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="ba-lead text-sm shrink-0">
Check one or more rows to reveal the bulk-actions bar. Pick an action; the
grid mutates locally (your backend call goes where the action handlers are).
</div>
<!-- Sticky bulk action bar - always visible. The action buttons are
disabled while nothing is selected so the layout doesn't jump
when the user checks/unchecks rows. -->
<div
class="shrink-0 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2"
style="border-color: var(--sg-accent, #6366f1); background: color-mix(in srgb, var(--sg-accent, #6366f1) 8%, transparent);"
>
<span class="text-sm font-semibold" style="color: var(--sg-fg);">
{#if n > 0}{n} selected{:else}No rows selected{/if}
</span>
<span class="ba-dot mx-1">·</span>
<button type="button" class="action-btn" disabled={n === 0} onclick={markInStock}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
Mark in stock
</button>
<button type="button" class="action-btn" disabled={n === 0} onclick={exportSelectedTsv}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
Copy as TSV
</button>
<button type="button" class="action-btn action-btn-danger" disabled={n === 0} onclick={deleteSelected}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" />
</svg>
Delete
</button>
<button type="button" class="action-btn ml-auto" disabled={n === 0} onclick={clearSelection}>
Clear
</button>
</div>
{#if toast}
<p class="shrink-0 text-xs" style="color: var(--ba-ok);">{toast}</p>
{/if}
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="menu"
selectionMode="row"
showRowSelection={true}
showRowNumbers={true}
enableInlineEditing={false}
rowHeight={36}
containerHeight="100%"
fitColumns={true}
onApiReady={(next) => (api = next)}
onRowSelectionChange={(_, sel) => (selectedRows = sel)}
/>
</div>
<footer class="ba-foot text-xs shrink-0">
{rows.length} rows total · the toolbar appears only while rows are selected.
</footer>
</section>
<style>
/* Success tone is semantic, so it keeps its own light/dark pair instead of
following the theme accent. */
section { --ba-ok: #15803d; }
:global([data-theme='dark']) section { --ba-ok: #4ade80; }
.ba-lead { color: var(--sg-fg, #475569); }
.ba-foot { color: var(--sg-muted, #64748b); }
.ba-dot { color: var(--sg-muted, #94a3b8); }
.action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--sg-border);
background: var(--sg-bg);
color: var(--sg-fg);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.action-btn:hover:not(:disabled) {
background: var(--sg-row-hover-bg);
border-color: color-mix(in srgb, var(--sg-accent, #6366f1) 35%, var(--sg-border));
}
.action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.action-btn-danger {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 28%, var(--sg-border));
}
.action-btn-danger:hover {
background: color-mix(in srgb, #ef4444 8%, transparent);
border-color: color-mix(in srgb, #ef4444 55%, var(--sg-border));
}
</style>More Selection & Clipboard examples
- Bulk-action bar - selectionBar floats a bar over the grid while rows are selected - the count, your actions, an overflow menu, and Clear - the pattern an issue tracker uses for bulk edit. position pins it to the bottom (default) or top edge. Actions receive the selected rows in display order, and hidden / disabled re-check against the live selection so buttons appear and grey out as the set changes.
- Selection + copy/paste - Row + cell-range selection with TSV clipboard round-trip.
- Selection API + events - Drive cell selection with api.selectCells / api.getSelected; subscribe to changes via onCellSelectionChange. Live SUM/AVG/MIN/MAX panel + event log + copy-as-TSV.
- Status bar (range aggregates) - Excel-style bar under the grid with live aggregates of the selected cell range: Count, Sum, Avg, Min, Max. Enable with statusBar + enableCellSelection, then drag a rectangle across numeric cells. Choose the aggregate set via statusBar={ aggregates }.
- Range selection (Excel-style) - Drag any rectangle of cells, or Ctrl/Cmd+drag to add MORE ranges - all stay highlighted and copy together. Toolbar issues common ranges; live SUM/AVG/MIN/MAX/COUNT status bar; copy as TSV.