How to check where focus lands after a validation error
Submit the form, then read document.activeElement before touching anything. Chrome moved focus to the first invalid control here. A preventDefault() handler that only renders an error left it on the submit button, and the next Tab went to the link after the form, not back to the field.
Why check this
Focus is where the next keystroke goes. After a rejected submit it decides how many presses separate a keyboard user from the field that failed. A mouse user reads the red text and clicks it. Nobody on a keyboard can.
Run this at sign-off on any form that rejects input, and after every change to the submit handler, which is where the caret gets abandoned. The failure it prevents is a checkout a keyboard user cannot finish: the error is on screen, the caret is on the submit button, and Tab moves out of the form.
The measurement is document.activeElement, read immediately after the submit is blocked. Which controls block it is settled by How to check required field validation.
Prerequisites
- Node 22 and port 8931 free. Every request goes to
127.0.0.1. - Chrome and
puppeteer-core(npm i puppeteer-core). SetCHROMEif your binary is elsewhere. --lang=en-USin the launch arguments: validation text follows the browser UI locale, and this machine returns Ukrainian without it.- Browser figures are one capture on one machine, on 2026-09-12.
activeElementis defined in the HTML standard.
target.mjs serves four forms that fail four ways.
// target.mjs: four forms that fail in four different ways, on 127.0.0.1:8931.
import { createServer } from 'node:http';
const PORT = 8931;
const SPACER = '<div style="height:900px;background:#f5f5f4"></div>';
const shell = (title, body) => `<!doctype html><html lang="en"><meta charset="utf-8">
<title>${title}</title><style>body{font:16px system-ui;margin:0;padding:16px}
label{display:block;margin:8px 0}.err{color:#b91c1c;font-weight:600}</style>${body}`;
// 1. The browser blocks the submit. Two forms: one visible, one with a hidden required control.
const NATIVE = shell('native', `
<form id="f" method="post" action="/store">
<label>Nickname <input id="nickname" name="nickname" type="text"></label>
<label>Email <input id="email" name="email" type="email" required></label>
<label>City <input id="city" name="city" type="text" required></label>
<button id="go" type="submit">Create account</button>
</form>
<form id="hf" method="post" action="/store">
<input id="token" name="token" type="text" required hidden>
<label>Note <input id="note" name="note" type="text"></label>
<button id="hgo" type="submit">Send note</button>
</form>
<a id="help" href="/help">Need help?</a>`);
// 2. Your own script blocks the submit. fix=none|focus|scroll picks what it does after that.
const script = (fix) => shell('script', `
${SPACER}
<form id="f" novalidate method="post" action="/store">
<label>Email <input id="email" name="email" type="text"></label>
<p class="err" id="err-email" hidden>Enter your email address.</p>
${SPACER}
<label>City <input id="city" name="city" type="text"></label>
<p class="err" id="err-city" hidden>Enter your city.</p>
<button id="go" type="submit">Create account</button>
</form>
<a id="help" href="/help">Need help?</a>
<script>
const form = document.getElementById('f');
form.addEventListener('submit', (e) => {
e.preventDefault();
const bad = [...form.elements].filter((el) => el.tagName === 'INPUT' && el.value.trim() === '');
for (const el of bad) document.getElementById('err-' + el.id).hidden = false;
${fix === 'focus' ? 'if (bad[0]) bad[0].focus();' : ''}
${fix === 'scroll' ? "if (bad[0]) document.getElementById('err-' + bad[0].id).scrollIntoView();" : ''}
});
</script>`);
// 3. The error summary repair. container=plain has no tabindex, container=tabindex has tabindex="-1".
const summary = (container) => shell('summary', `
<div id="summary" class="err" ${container === 'tabindex' ? 'tabindex="-1"' : ''} hidden>
<h2>2 problems with this form</h2>
<ul><li><a href="#email">Enter your email address.</a></li><li><a href="#city">Enter your city.</a></li></ul>
</div>
<form id="f" novalidate method="post" action="/store">
<label>Email <input id="email" name="email" type="text"></label>
<label>City <input id="city" name="city" type="text"></label>
<button id="go" type="submit">Create account</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', (e) => {
e.preventDefault();
const box = document.getElementById('summary');
box.hidden = false;
box.focus();
});
</script>`);
// 4. The server round trip. The POST answers with a new document carrying the error.
const roundtrip = (failed) => shell('roundtrip', `
${SPACER}
<form id="f" method="post" action="/roundtrip">
<label>Email <input id="email" name="email" type="text" value=""></label>
${failed ? '<p class="err" id="err-email">Enter your email address.</p>' : ''}
<label>City <input id="city" name="city" type="text"></label>
<button id="go" type="submit">Create account</button>
</form>
<a id="help" href="/help">Need help?</a>`);
const html = (res, body) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end(body);
};
createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (url.pathname === '/native') return html(res, NATIVE);
if (url.pathname === '/script') return html(res, script(url.searchParams.get('fix') ?? 'none'));
if (url.pathname === '/summary') return html(res, summary(url.searchParams.get('container') ?? 'plain'));
if (url.pathname === '/roundtrip' && req.method === 'GET') return html(res, roundtrip(false));
if (url.pathname === '/roundtrip' && req.method === 'POST') {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => html(res, roundtrip(true)));
return;
}
res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
}).listen(PORT, '127.0.0.1', () => console.log('focus fixture on http://127.0.0.1:' + PORT));
probe.mjs drives Chrome, one mode per step. Tab goes through CDP, so the browser moves focus, not the script.
// probe.mjs <mode> : reads document.activeElement after a blocked submit. One mode per step.
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const BASE = 'http://127.0.0.1:8931';
const mode = process.argv[2] ?? 'native';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
const logged = [];
page.on('console', (m) => { if (!m.text().includes('Failed to load resource')) logged.push(m.type() + ': ' + m.text()); });
// One string for whatever holds the caret, plus what the person can see of it.
const WHERE = `(() => {
const a = document.activeElement;
const name = a === document.body ? 'BODY' : a.tagName + (a.id ? '#' + a.id : '');
return { active: name, tabIndex: a.tabIndex };
})()`;
const seen = (id) => `(() => {
const el = document.getElementById(${JSON.stringify(id)});
if (!el || el.hidden) return 'absent';
const r = el.getBoundingClientRect();
return (r.top >= 0 && r.bottom <= innerHeight) ? 'in viewport' : 'off screen, top ' + Math.round(r.top) + 'px of ' + innerHeight;
})()`;
const tab = async (cdp) => {
const key = { windowsVirtualKeyCode: 9, nativeVirtualKeyCode: 9, key: 'Tab', code: 'Tab' };
await cdp.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...key });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', ...key });
await sleep(60);
};
if (mode === 'native') {
await page.goto(BASE + '/native', { waitUntil: 'load' });
await page.focus('#city');
console.log('focus before the submit: ' + (await page.evaluate(WHERE)).active);
await page.click('#go');
await sleep(400);
const w = await page.evaluate(WHERE);
console.log('still on ' + new URL(page.url()).pathname);
console.log('focus after the blocked submit: ' + w.active);
console.log('invalid controls in document order: ' + await page.evaluate(
() => [...document.getElementById('f').elements].filter((e) => e.willValidate && !e.checkValidity()).map((e) => e.id).join(' ')));
console.log('console: ' + (logged.join(' | ') || '(empty)'));
} else if (mode === 'hidden') {
await page.goto(BASE + '/native', { waitUntil: 'load' });
await page.focus('#hgo');
await page.click('#hgo');
await sleep(600);
console.log('still on ' + new URL(page.url()).pathname);
console.log('focus after the blocked submit: ' + (await page.evaluate(WHERE)).active);
console.log(await page.evaluate(() => {
const t = document.getElementById('token');
return 'token willValidate ' + t.willValidate + ' valueMissing ' + t.validity.valueMissing
+ ' computed display ' + getComputedStyle(t).display;
}));
console.log('console: ' + (logged.join(' | ') || '(empty)'));
} else if (mode === 'script' || mode === 'script-focus' || mode === 'script-scroll') {
const fix = { script: 'none', 'script-focus': 'focus', 'script-scroll': 'scroll' }[mode];
await page.goto(BASE + '/script?fix=' + fix, { waitUntil: 'load' });
await page.click('#go');
await sleep(400);
const w = await page.evaluate(WHERE);
console.log('repair: ' + fix);
console.log('focus after the blocked submit: ' + w.active);
console.log('error next to the failing field: ' + await page.evaluate(seen('err-email')));
console.log('page scrollY: ' + await page.evaluate(() => Math.round(scrollY)));
} else if (mode === 'tab') {
await page.goto(BASE + '/script?fix=none', { waitUntil: 'load' });
await page.click('#go');
await sleep(400);
const cdp = await page.createCDPSession();
console.log('after the blocked submit: ' + (await page.evaluate(WHERE)).active);
for (let i = 1; i <= 4; i += 1) {
await tab(cdp);
console.log('Tab ' + i + ':' + ' '.repeat(20) + (await page.evaluate(WHERE)).active);
}
} else if (mode === 'summary-plain' || mode === 'summary-tabindex') {
const container = mode === 'summary-plain' ? 'plain' : 'tabindex';
await page.goto(BASE + '/summary?container=' + container, { waitUntil: 'load' });
console.log('container ' + container + ': ' + await page.evaluate(() => {
const b = document.getElementById('summary');
return 'tabindex attribute ' + b.hasAttribute('tabindex') + ', el.tabIndex ' + b.tabIndex;
}));
await page.click('#go');
await sleep(400);
console.log('summary shown: ' + await page.evaluate(seen('summary')));
console.log('focus after box.focus(): ' + (await page.evaluate(WHERE)).active);
} else if (mode === 'roundtrip') {
await page.goto(BASE + '/roundtrip', { waitUntil: 'load' });
await page.focus('#email');
console.log('before the submit: focus ' + (await page.evaluate(WHERE)).active
+ ', scrollY ' + await page.evaluate(() => Math.round(scrollY)));
await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
await sleep(400);
console.log('landed on ' + new URL(page.url()).pathname);
console.log('focus on the new document: ' + (await page.evaluate(WHERE)).active);
console.log('scrollY on the new document: ' + await page.evaluate(() => Math.round(scrollY)));
console.log('error text: ' + await page.evaluate(seen('err-email')));
const cdp = await page.createCDPSession();
await tab(cdp);
console.log('first Tab lands on: ' + (await page.evaluate(WHERE)).active);
}
console.log('chrome ' + await browser.version());
await browser.close();
Steps
- Step 1.
Start the fixture in its own shell.
node target.mjsfocus fixture on http://127.0.0.1:8931Record the PID from
netstat -ano | grep 8931, so you stop this process and no other. - Step 2.
Submit the browser-validated form with the caret in a later field.
node probe.mjs nativefocus before the submit: INPUT#city still on /native focus after the blocked submit: INPUT#email invalid controls in document order: email city console: (empty) chrome Chrome/152.0.7977.76The caret started on
cityand the browser moved it back toemail, the first invalid control in document order, not the one the person was in. - Step 3.
Submit the second form, whose required control carries
hidden.node probe.mjs hiddenstill on /native focus after the blocked submit: BUTTON#hgo token willValidate true valueMissing true computed display none console: error: An invalid form control with name='token' is not focusable. chrome Chrome/152.0.7977.76The submit is refused and the caret does not move. Chrome cannot anchor a bubble to a box with
display: none, so the console line is the only evidence. - Step 4.
Let your own handler block the submit and render the error.
node probe.mjs scriptrepair: none focus after the blocked submit: BUTTON#go error next to the failing field: off screen, top -436px of 600 page scrollY: 1399 chrome Chrome/152.0.7977.76Focus stayed on the button that was clicked, and the message sits 436 px above a 600 px viewport. A screenshot shows one of those two faults.
- Step 5.
Press Tab four times after that error.
node probe.mjs tabafter the blocked submit: BUTTON#go Tab 1: A#help Tab 2: BODY Tab 3: INPUT#email Tab 4: INPUT#city chrome Chrome/152.0.7977.76This sequence is the finding. The first Tab leaves the form for the link after it, the second leaves the document, and only the third reaches the field that failed. A mouse user sees none of it.
- Step 6.
Submit through the server and read the caret on the new page.
node probe.mjs roundtripbefore the submit: focus INPUT#email, scrollY 446 landed on /roundtrip focus on the new document: BODY scrollY on the new document: 0 error text: off screen, top 963px of 600 first Tab lands on: INPUT#email chrome Chrome/152.0.7977.76A new document carries nothing over: the caret is on the body, the 446 px scroll position is gone, and the error sits 963 px down a 600 px viewport.
- Step 7.
Scroll the error into view and change nothing else.
node probe.mjs script-scrollrepair: scroll focus after the blocked submit: BUTTON#go error next to the failing field: in viewport page scrollY: 963 chrome Chrome/152.0.7977.76scrollIntoViewmoved the page and left the caret where it was. An assertion that the error is visible passes here, while step 5 still describes the keyboard path. - Step 8.
Call
focus()on the first invalid control instead.node probe.mjs script-focusrepair: focus focus after the blocked submit: INPUT#email error next to the failing field: in viewport page scrollY: 637 chrome Chrome/152.0.7977.76One call answers both readings. The caret is in the failing field, and the browser scrolled to 637 px rather than step 7's 963 px, aligning the control rather than the message.
- Step 9.
Take the other repair: focus an error summary container.
node probe.mjs summary-plaincontainer plain: tabindex attribute false, el.tabIndex -1 summary shown: in viewport focus after box.focus(): BUTTON#go chrome Chrome/152.0.7977.76The summary is on screen,
box.focus()ran, and the caret did not move.focus()on an element that cannot take focus throws nothing and returns nothing, so the defect leaves no trace. - Step 10.
Serve the same container with
tabindex="-1"on it.node probe.mjs summary-tabindexcontainer tabindex: tabindex attribute true, el.tabIndex -1 summary shown: in viewport focus after box.focus(): DIV#summary chrome Chrome/152.0.7977.76Now the caret is in the summary and Tab continues into the links it holds. The attribute is the whole difference, and
el.tabIndexreads-1in both runs.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| activeElement is the first invalid control | The browser's own validation moved the caret | Nothing. Step 2 is the reproduction on a form with no script. |
| activeElement is the submit button after your handler ran | The handler rendered a message and moved nothing | Call focus() on the first invalid control, as step 8 does, then re-run step 5. |
| A refused submit with the caret unmoved and nothing on screen | An invalid control cannot be focused | Read the console. Step 3 hides one and prints the only line it produces. |
| activeElement is BODY after the response | A new document restarted the tab order and the scroll | Move focus from the load path. Step 6 measures both losses. |
| focus() on a container and activeElement unchanged | The container is not focusable | Add tabindex="-1" and re-read. Steps 9 and 10 are that pair. |
| The error is in the viewport and activeElement did not move | The page scrolled, the caret did not | Compare step 7 with step 8 and assert on both readings. |
| The first Tab after the error leaves the form | The tab order continues from the button | Count the presses back to the failing field, as step 5 does. |
Common mistakes
What to check next
- How to test form data is kept after a failed submit: which typed values survive step 6.
- How to check required field validation: which controls block the submit at all.
- How to test double form submission: what a second Enter costs with the caret on the button.
- How to test that client validation is enforced on the server: why step 6 exists.
- How to check visible focus indicator: whether the caret's new home can be seen.
FAQ
Where should focus go after a validation error?
To the first invalid control, or an error summary carrying tabindex="-1". Steps 8 and 10 measure both arriving.
Does the browser move focus by itself?
On its own constraint validation, yes. Step 2 shows Chrome moving the caret from city to email. After preventDefault(), it does nothing.
Why does focus() on my error summary do nothing?
The container is not focusable. Step 9 runs focus() on a div with no tabindex and activeElement does not change. Nothing is thrown.
What does a screen reader announce here?
This page measures none of that, and no screen reader is installed here. Announcement belongs to How to test aria live regions.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0Windows 11 build 22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny