diff --git a/frontend/src/components/ui/PageScroller.tsx b/frontend/src/components/ui/PageScroller.tsx
index 1a49126e..0a98c9b7 100644
--- a/frontend/src/components/ui/PageScroller.tsx
+++ b/frontend/src/components/ui/PageScroller.tsx
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useScrollFade } from "../../lib/useScrollFade";
+import { useFocusKeeper } from "../../lib/useFocusKeeper";
import { StickyTopProvider } from "./stickyTop";
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
@@ -105,6 +106,10 @@ export default function PageScroller({
[fadeRef],
);
+ // A focused control removed by its own action (a row gone after Approve, a toolbar after Clear
+ // all) leaves the focus in its slot rather than on
— see useFocusKeeper.
+ useFocusKeeper(elRef);
+
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
const scrollKeyRef = useRef(scrollKey);
scrollKeyRef.current = scrollKey;
diff --git a/frontend/src/lib/useFocusKeeper.dom.test.tsx b/frontend/src/lib/useFocusKeeper.dom.test.tsx
new file mode 100644
index 00000000..7bdf765b
--- /dev/null
+++ b/frontend/src/lib/useFocusKeeper.dom.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from "vitest";
+import { act, cleanup, render } from "@testing-library/react";
+import { useRef, useState } from "react";
+
+import { useFocusKeeper } from "./useFocusKeeper";
+
+afterEach(cleanup);
+
+const flush = () => act(() => new Promise((r) => setTimeout(r, 0)));
+
+function Rows({ nav = false }: { nav?: boolean }) {
+ const ref = useRef(null);
+ useFocusKeeper(ref);
+ const [rows, setRows] = useState(["a", "b", "c"]);
+ return (
+
+
Invites
+ {rows.map((r, i) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+const btn = (name: string) =>
+ [...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
+
+describe("useFocusKeeper", () => {
+ it("keeps the focus in the removed row's slot — the next row takes it", async () => {
+ render();
+ btn("approve b").focus();
+ act(() => btn("approve b").click());
+ await flush();
+ expect(document.activeElement).toBe(btn("approve c"));
+ });
+
+ it("falls back to what followed the list once the last row is gone", async () => {
+ render();
+ btn("approve c").focus();
+ act(() => btn("approve c").click());
+ await flush();
+ expect(document.activeElement).toBe(btn("after the list"));
+ });
+
+ it("leaves a keyboard list's rows to listNav", async () => {
+ render();
+ btn("approve b").focus();
+ act(() => btn("approve b").click());
+ await flush();
+ expect(document.activeElement).toBe(document.body);
+ });
+});
diff --git a/frontend/src/lib/useFocusKeeper.ts b/frontend/src/lib/useFocusKeeper.ts
new file mode 100644
index 00000000..86c30e55
--- /dev/null
+++ b/frontend/src/lib/useFocusKeeper.ts
@@ -0,0 +1,79 @@
+import { useEffect, type RefObject } from "react";
+
+// When the element holding the keyboard focus is REMOVED by what it just did — an invite row gone
+// after Approve, a toolbar gone after "Clear all", a section gone once its last row is handled — the
+// browser drops the focus to , and the next Tab starts over at the top of the document. That is
+// the same stranding FocusSafeButton fixes for a button that merely turns disabled, one step further.
+//
+// So the page keeps the focus in place: the first focusable thing now in the removed node's slot —
+// in what followed it, else what preceded it, else the nearest ancestor still on the page. Scoped to
+// one container (the page scroller), which is what keeps it off the layers with their own focus
+// logic: a menu or a dialog is portaled outside it and returns the focus to its opener itself.
+// Keyboard lists (`listNav`) are left alone as well — their `j` already goes on from the slot, and a
+// row that merely scrolled out of a virtualized list must not pull the focus after it.
+
+const FOCUSABLE =
+ 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null {
+ let n: Node | null = node;
+ while (n) {
+ if (n instanceof HTMLElement) {
+ if (n.matches(FOCUSABLE)) return n;
+ const all = n.querySelectorAll(FOCUSABLE);
+ const hit = fromEnd ? all[all.length - 1] : all[0];
+ if (hit) return hit;
+ }
+ n = fromEnd ? n.previousSibling : n.nextSibling;
+ }
+ return null;
+}
+
+/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page. */
+function slotFor(record: MutationRecord, lost: Node): HTMLElement | null {
+ const removed = [...record.removedNodes].find((n) => n === lost || n.contains(lost));
+ if (!removed) return null;
+ const after = firstFocusable(record.nextSibling);
+ if (after) return after;
+ const before = firstFocusable(record.previousSibling, true);
+ if (before) return before;
+ let up: Node | null = record.target;
+ while (up && up instanceof HTMLElement) {
+ const inside = firstFocusable(up.firstChild);
+ if (inside) return inside;
+ up = up.parentElement;
+ }
+ return null;
+}
+
+export function useFocusKeeper(ref: RefObject): void {
+ useEffect(() => {
+ const root = ref.current;
+ if (!root) return;
+ let held: HTMLElement | null = null;
+ const onFocusIn = (e: FocusEvent) => {
+ const t = e.target as HTMLElement | null;
+ held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null;
+ };
+ const watch = new MutationObserver((records) => {
+ const lost = held;
+ if (!lost || lost.isConnected) return;
+ held = null;
+ const active = document.activeElement;
+ if (active && active !== document.body) return; // something already took the focus
+ for (const r of records) {
+ const target = slotFor(r, lost);
+ if (target) {
+ target.focus({ preventScroll: true });
+ return;
+ }
+ }
+ });
+ document.addEventListener("focusin", onFocusIn);
+ watch.observe(root, { childList: true, subtree: true });
+ return () => {
+ document.removeEventListener("focusin", onFocusIn);
+ watch.disconnect();
+ };
+ }, [ref]);
+}