Preserving Focus When Rows Are Deleted
Permalink to "Preserving Focus When Rows Are Deleted"When a user deletes a row with a button inside that row, the button is destroyed along with the row. Unless the application moves focus somewhere, the browser drops it to <body>, and the next Tab press starts from the top of the page — past the header, the navigation, the filters — while a screen reader may announce nothing at all. For a user deleting a dozen stale records one by one, that is a dozen trips back from the top.
This page defines where focus should go after a deletion and how to get it there. It belongs to focus management in single-page apps.
Spec reference
Permalink to "Spec reference"There is no specific ARIA attribute for this; it is a focus-order requirement. SC 2.4.3 Focus Order (Level A) requires that focusable components receive focus in an order that preserves meaning and operability — losing focus to the document start breaks that order. SC 4.1.3 Status Messages covers the confirmation that the row was deleted, since the deletion happens away from the user’s new focus point.
When the deletion goes through a confirmation dialog, the dialog’s own focus-return logic applies first: the dialog normally returns focus to its trigger, but the trigger is inside the deleted row. The rules on this page decide the substitute. That handover is also discussed in restoring focus after closing complex modals.
When to follow this pattern — and when not to
Permalink to "When to follow this pattern — and when not to"Apply it to every destructive row action in a list, table or grid: delete, archive, remove from list, move to another folder. Any action that makes the current row disappear from the current view needs a focus target.
The “same control in the next row” rule suits repetitive work — a user deleting several rows in a row can keep pressing the same key. If rows have many controls, focusing the row itself (in a grid) or the row’s primary link (in a table) is a reasonable alternative; be consistent across the product.
The misapplication to name is moving focus to the top of the table or to its caption after every single-row deletion. It is better than <body>, but the user loses their place in a long list every time. Reserve the caption target for bulk deletes, where there is no single “next row”.
Annotated code example
Permalink to "Annotated code example"// Compute the target BEFORE the row is removed
function focusTargetAfterDelete(row) {
const next = row.nextElementSibling;
const prev = row.previousElementSibling;
const pick = next ?? prev;
if (pick) {
// Same control in the neighbouring row, if it has one
const same = pick.querySelector(`[data-action="${document.activeElement?.dataset.action}"]`);
return same ?? pick.querySelector('a, button') ?? pick;
}
return document.getElementById('empty-state'); // tabindex="-1" message
}
async function deleteRow(row) {
const target = focusTargetAfterDelete(row); // 1. decide first
const name = row.dataset.name;
await api.delete(row.dataset.id);
row.remove(); // 2. remove
target.focus(); // 3. SC 2.4.3: land nearby
// 4. SC 4.1.3: say what happened; focus announcement follows
status(`${name} deleted. ${countRows()} invoices remaining.`);
}
<!-- Empty state as a focus target once the last row is gone -->
<div id="empty-state" tabindex="-1" hidden>
<h3>No invoices</h3>
<p>All invoices have been deleted. <a href="/invoices/new">Create an invoice</a>.</p>
</div>
// React: same decision, applied after commit
const pending = useRef(null);
function onDelete(id) {
const idx = rows.findIndex((r) => r.id === id);
pending.current = rows[idx + 1]?.id ?? rows[idx - 1]?.id ?? 'empty';
setRows((rs) => rs.filter((r) => r.id !== id));
}
useLayoutEffect(() => {
if (!pending.current) return;
const sel = pending.current === 'empty' ? '#empty-state' : `[data-row-id="${pending.current}"] [data-action="delete"]`;
document.querySelector(sel)?.focus();
pending.current = null;
}, [rows]);
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Scenario | Focus lands on | Expected announcement |
|---|---|---|
| Delete row 5 of 20 | Delete button in the new row 5 | “Delete INV-1047, button” then “INV-1046 deleted. 19 invoices remaining.” |
| Delete the last row | Delete button in the new last row | “Delete INV-1063, button” then the status |
| Delete the only row | Empty state | “No invoices, heading level 3” then the status |
| Bulk delete rows 5–9 | First row after the block, or caption | Row or caption, then “5 invoices deleted.” |
| Delete via confirm dialog | As above, after the dialog closes | Dialog close, then target, then status |
Integration context
Permalink to "Integration context"Deletion is usually preceded by confirmation or followed by undo. The trade-offs are in confirming and undoing destructive row actions; with undo, the status message should mention it (“Press Control Z to undo”), and undoing should restore focus to the restored row.
In React, compute the target before the state update and apply it in a layout effect, as in the example; the general hook is in focus management in React with refs and effects. Bulk deletions from a selection toolbar follow the same rules with a different target, as described in contextual bulk action toolbars.
Gotchas
Permalink to "Gotchas"Virtualised lists. The next row may not be rendered. Compute the target by data index, scroll it into view, wait for render, then focus.
Server-confirmed deletes. If the delete fails, the row stays and focus should stay on its button, with an assertive error.
Sorting after deletion. If deletion triggers a re-sort or re-fetch, the “next” row may move. Record the target by id, not by DOM position, and focus it after the refresh.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Where should focus go after deleting a table row?
To the same control in the next row, so repetitive deletes keep working with the same key. If the deleted row was last, use the previous row; if no rows remain, focus a focusable empty-state message.
Why does focus disappear when I delete a row?
Because the focused button was inside the removed row. When a focused element is removed, browsers move focus to the document body, and the next Tab starts from the top of the page.
Should the deletion be announced if focus moves to the next row?
Yes. Focus moving to the next row announces that row, not the deletion. A short status message such as “INV-1046 deleted, 19 remaining” confirms the action.
How do I handle focus after deleting through a confirmation dialog?
Compute the substitute target before opening the dialog, since the dialog’s usual return target is inside the row being deleted. When the dialog closes after a successful delete, focus the substitute instead.
Related
Permalink to "Related"- Focus management in React — the restoration hook
- Confirming and undoing destructive actions — what happens before the delete
- Contextual bulk action toolbars — bulk deletes and focus