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.

Where focus goes after a deletion Decision tree for the focus target after deleting a row: the next row if one exists, otherwise the previous row, otherwise the table's empty state. Where focus goes after a deletionIs there a row after the deleted one?YesNext rowsame control, e.g. its Delete buttonNo, but one beforePrevious rowlast row is now the one aboveNo rows leftEmpty statefocusable message with next steps
Next row first matches what sighted users see — the row below moves up into the same position.

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
One deletion, keyboard and speech Timeline of deleting a row by keyboard: focus on the Delete button, confirmation, row removed, focus moved to the next row's Delete button, status spoken. One deletion, keyboard and speechFocus"Delete INV-1046, button"Enterconfirm or delete immediatelyTarget chosennext row's Delete buttonRow removedDOM updatedFocus + status"Delete INV-1047" … "INV-1046deleted"one row deletion
The target is computed before the row is removed — afterwards, its neighbours are harder to find.

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.

Focus targets after deletion, compared Comparison of weak focus targets after a row deletion — body, top of page, caption — against the next or previous row. Focus targets after deletion, compared✗ Weak targetsbody — next Tab starts at the top of the pagePage heading — far from the workCaption after every single delete — loses placeThe Delete button that no longer exists✓ Strong targetsSame control in the next rowPrevious row when the last row was deletedEmpty state when nothing remainsCaption only after bulk deletes
The best target is the one that keeps the user where they were working.

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.

Permalink to "Related"

← Back to Focus Management in Single-Page Apps