
An Actions Column (Edit, Delete) in SvGrid
Build a per-row actions column with edit, delete, and duplicate buttons in SvGrid - handling data mutations, accessibility, and confirmation flows correctly.
Every admin interface ends up with the same column on the far right: a row of small buttons that let the user edit, delete, or do something quick with that record. It looks trivial to add, and the basic case is. What trips people up is everything around it - confirmation dialogs, optimistic updates, keyboard accessibility, and the fact that a destructive button in every row is genuinely dangerous if the UX is careless.
Here is how to do it properly in SvGrid.
The column has no field
An actions column is pure UI - no underlying data field, no sorting, no filtering. In SvGrid, that means defining a column with only an id and a cell renderer, and skipping field entirely.
The cell property accepts a Svelte 5 snippet. Snippets have access to the cell context, which includes the original row object. That is all you need.
<script lang="ts">
import SvGrid, { renderSnippet, type ColumnDef } from '@svgrid/grid'
type Employee = { id: number; name: string; role: string; salary: number }
let data = $state<Employee[]>([
{ id: 1, name: 'Ana Folau', role: 'Engineer', salary: 95000 },
{ id: 2, name: 'Chris Ndegwa', role: 'Designer', salary: 88000 },
{ id: 3, name: 'Sara Petrov', role: 'PM', salary: 102000 },
])
function editRow(row: Employee) {
// open your modal, panel, or inline form
console.log('editing', row)
}
function deleteRow(row: Employee) {
data = data.filter((r) => r.id !== row.id)
}
function duplicateRow(row: Employee) {
const copy = { ...row, id: Date.now() }
data = [...data, copy]
}
</script>
{#snippet actionsCell({ row }: { row: { original: Employee } })}
<span class="row-actions">
<button type="button" aria-label={`Edit ${row.original.name}`} onclick={() => editRow(row.original)}>
Edit
</button>
<button type="button" aria-label={`Duplicate ${row.original.name}`} onclick={() => duplicateRow(row.original)}>
Copy
</button>
<button type="button" class="danger" aria-label={`Delete ${row.original.name}`} onclick={() => deleteRow(row.original)}>
Delete
</button>
</span>
{/snippet}
<script lang="ts">
const columns: ColumnDef<any, Employee>[] = [
{ id: 'name', field: 'name', header: 'Name', width: 200 },
{ id: 'role', field: 'role', header: 'Role', width: 140 },
{ id: 'salary', field: 'salary', header: 'Salary', width: 120, type: 'number' },
{
id: 'actions',
header: '',
width: 160,
pinned: 'right',
cell: (c) => renderSnippet(actionsCell, { row: c.row }),
},
]
</script>
<SvGrid {data} {columns} />
Two things to note: field is absent from the actions column, and pinned: 'right' keeps it visible when the user scrolls horizontally on a wide grid.
Wiring mutations through the imperative API
If you are managing data locally with $state, direct array mutation is fine. But many apps talk to a backend, and you want the grid to stay in sync even when a request fails. The onApiReady callback gives you an API object with addRow, removeRow, and applyTransaction.
<script lang="ts">
import SvGrid, { renderSnippet, type SvGridApi, type ColumnDef } from '@svgrid/grid'
type Product = { id: string; name: string; price: number; stock: number }
let api = $state<SvGridApi | null>(null)
let data = $state<Product[]>([])
async function handleDelete(row: Product) {
if (!confirm(`Remove "${row.name}" from the catalog?`)) return
// Optimistic: remove from grid immediately
api?.applyTransaction({ remove: [row] })
try {
await fetch(`/api/products/${row.id}`, { method: 'DELETE' })
} catch (err) {
// Rollback: put it back if the server call fails
api?.applyTransaction({ add: [row] })
console.error('Delete failed, rolled back', err)
}
}
async function handleEdit(row: Product) {
// After your modal resolves with updated data:
const updated = await openEditModal(row)
if (!updated) return
await fetch(`/api/products/${row.id}`, {
method: 'PUT',
body: JSON.stringify(updated),
})
api?.applyTransaction({ update: [updated] })
}
</script>
{#snippet actionCell({ row }: { row: { original: Product } })}
<span class="row-actions">
<button type="button" onclick={() => handleEdit(row.original)}>Edit</button>
<button type="button" class="danger" onclick={() => handleDelete(row.original)}>Delete</button>
</span>
{/snippet}
<SvGrid
{data}
columns={[
{ id: 'name', field: 'name', header: 'Name', width: 220 },
{ id: 'price', field: 'price', header: 'Price', width: 100, type: 'number' },
{ id: 'stock', field: 'stock', header: 'Stock', width: 100, type: 'number' },
{ id: 'actions', header: '', width: 140, pinned: 'right',
cell: (c) => renderSnippet(actionCell, { row: c.row }) },
]}
onApiReady={(a) => { api = a }}
/>
The optimistic pattern - remove from the grid first, then hit the server, then restore on failure - makes the UI feel instant while keeping the data consistent.
Accessible buttons, not clickable spans
The most common failure mode I see in action columns: a <div> or <span> with an onclick handler instead of a <button>. That looks fine visually but breaks keyboard navigation and screen readers. A user who navigates with Tab or a keyboard shortcut cannot reach those elements at all.
SvGrid's cell renderer lets you put actual <button> elements in cells. They get proper focus handling automatically because they are native interactive elements. Use them.
For icon-only buttons (where the label is just a trash can icon), an aria-label is required - not optional, required. The label should describe the action AND the target: aria-label="Delete Ana Folau" is far more useful than aria-label="Delete" when a screen reader announces a focused button. The snippet above threads the row name through to do this.
/* styles for the actions column - keep it compact */
.row-actions {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
}
.row-actions button {
padding: 3px 10px;
border: 1px solid var(--sg-border);
border-radius: var(--sg-radius);
background: var(--sg-bg);
color: var(--sg-fg);
cursor: pointer;
font-size: 0.8rem;
line-height: 1.4;
}
.row-actions button.danger {
border-color: #e55;
color: #e55;
}
.row-actions button:hover {
background: var(--sg-accent);
color: #fff;
border-color: var(--sg-accent);
}
.row-actions button.danger:hover {
background: #e55;
border-color: #e55;
color: #fff;
}
SvGrid exposes --sg-bg, --sg-fg, --sg-accent, --sg-border, and --sg-radius as CSS tokens, so styling buttons to match the current theme requires no extra configuration.
When per-row actions are not enough
Sometimes one user needs to delete thirty rows at once. Per-row buttons are the wrong tool for that - you want row selection plus a toolbar bulk action.
The two approaches work well together. Keep the actions column for quick single-record operations. Add rowSelectionFeature plus a toolbar delete button for multi-record workflows. Users learn which pattern to use quickly, because the affordances are visually distinct.
<script lang="ts">
import SvGrid, {
tableFeatures, rowSelectionFeature, renderSnippet,
type SvGridApi, type ColumnDef
} from '@svgrid/grid'
const features = tableFeatures({ rowSelectionFeature })
let api = $state<SvGridApi | null>(null)
async function deleteSelected() {
const selected = api?.getSelectedRows() ?? []
if (selected.length === 0) return
if (!confirm(`Delete ${selected.length} rows?`)) return
api?.applyTransaction({ remove: selected })
// then fire your batch API call
}
</script>
<button onclick={deleteSelected}>Delete selected</button>
<SvGrid
{data}
{columns}
{features}
rowSelection="multiple"
onApiReady={(a) => { api = a }}
/>
The two patterns - single-row action buttons and bulk selection - cover nearly every admin workflow. Pick the right one for each action rather than trying to make one handle both.
Confirmation UX
confirm() is synchronous and modal, which makes it easy to use but ugly in a polished app. For production, a custom dialog component is better. The pattern is the same: await a boolean result before proceeding with the destructive operation.
What you should never do is skip confirmation entirely for delete. Even with undo support, a delete with no friction trains users to be careless. One confirmation step - one click - is not too much to ask before removing a record.