I used to treat “tab through the page” as an a11y nicety — something for the checklist after visual QA. Then an agent shipped a polished settings drawer that looked fine with a mouse and was basically unusable from the keyboard.
Focus landed on a hidden close icon. Esc did nothing. The primary Save button was a <div onClick>. Arrow keys in a custom select ate the page scroll. Mouse users never noticed. Keyboard users bounced in under a minute.
So I added a hard gate: before merge, smoke the agent UI keyboard-only. No mouse. No trackpad. Five minutes, max. If I can’t finish the happy path, the PR isn’t done — green CI or not.
Why agents fail keyboard more than mouse
Coding agents optimize for what shows up in screenshots and Storybook canvases: layout, color, copy. Pointer paths are implicit (“click the button”). Keyboard paths are a second product:
- focus order
- visible focus rings
- Escape / Enter semantics
- roving tabindex in menus / lists
- which nodes are actually
<button>/<a>vs clickable divs
Unless you put keyboard constraints in the prompt and verify them by hand (or with a tiny Playwright keyboard script), the model ships the pretty mouse path and leaves the other half for support tickets.
The five-minute smoke (I do this on every agent UI PR)
Unplug the mouse metaphorically: don’t touch it.
- Land on the surface — refresh so focus starts where a real user would (URL bar → page, or skip link if you have one).
- Tab to the primary action — count stops. If it takes 20+ tabs through chrome you don’t care about, the surface needs a skip / better order.
- Complete the happy path with keyboard only — open the modal/drawer, fill required fields, submit, see success. Enter should activate the focused control; Space too for buttons.
-
Cancel / dismiss — Esc closes overlays; focus returns to the opener. If focus drops to
<body>, reject. - One “trap” check — open a menu or combobox, Arrow through options, Tab out (or Esc). Confirm you aren’t stuck inside a focus jail with no exit.
That’s it. Not a full WCAG audit. A smoke that catches the agent’s most common misses.
What I reject on sight
Patterns that keep showing up in agent diffs:
-
Clickable non-buttons —
<div role="button">withouttabIndex={0}and key handlers, or worse, bare<div onClick> -
outline: nonewith no replacement — focus invisible on dark panels the agent “cleaned up” - Modal without focus trap / restore — Tab escapes into the page under the overlay; Esc doesn’t close
- Custom select that only listens to click — looks like a Select, acts like a painting
- Disabled reasons only on hover title — keyboard users never see why Save is dead
- Auto-focus into a destructive field — agent “helpfully” focuses Delete on open
I paste the failing step into the review (“Tab 4 lands on Close before Title; Esc leaves drawer open”) and ask for a patch-sized fix, not a rewrite.
Make the agent earn the keyboard path up front
Don’t wait for review. Bake it into the task:
## Keyboard acceptance (required)
- All interactive controls reachable by Tab in a sensible order
- Visible focus ring on every focusable (no outline:none without :focus-visible replacement)
- Primary actions are <button> or <a href>, not div/span click handlers
- Overlay: Esc closes; focus returns to opener; Tab cycles inside while open
- Happy path completable keyboard-only (no mouse)
Do not mark done until you can describe the Tab sequence for the happy path in the PR.
When the agent lists the Tab sequence in the PR body, review gets faster — and the model usually fixes half the issues before you touch the branch.
Optional: one Playwright keyboard check
For surfaces agents touch often (auth, checkout, settings), I keep a tiny test that never moves the mouse:
test('settings drawer keyboard happy path', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Edit profile' }).focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await page.keyboard.press('Tab'); // name
await page.keyboard.type('Ada');
await page.keyboard.press('Tab'); // save
await page.keyboard.press('Enter');
await expect(page.getByText('Saved')).toBeVisible();
});
It’s brittle if you over-specify Tab counts — prefer getByRole + Enter/Space, and assert focus with toBeFocused() on the critical stops. One test beats another “looks good in Storybook” merge.
Tiny habit
Next agent UI PR: before you approve, put the mouse aside and Tab the happy path once. If you can’t Save and Esc cleanly, send it back with the exact stop that failed.
Agents are great at arranging pixels. Keyboard smoke is how you make sure a human who never touches the pointer can still finish the job.
Top comments (0)