How to check tap target size
Call getBoundingClientRect() on every link, button and input, and compare the rendered box against 24 by 24 CSS pixels for WCAG 2.5.8 and 44 by 44 for 2.5.5. On the test page below a button declared at width: 24px rendered 42 by 42, and an inline link rendered 20.4 by 27.0.
Why check this
Run this on any screen a user reaches on a phone, and after a design-system change that touches padding, icon size or line height. Those three decide the answer, not the width in the stylesheet.
The defect it catches is a control large enough to see and too small to hit. Beside it sits the opposite: a control that passes alone and fails because a second control is 2 px away. Both come out of one measurement.
Rendered box, not declared width
The box the finger hits is the border box the browser laid out. Padding adds to it, box-sizing decides whether it does, and an inline element takes its height from font metrics rather than line-height. Step 1 prints declared and measured side by side.
Prerequisites
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only and uses the Chrome already on the machine. SetCHROMEif yours is elsewhere. - Understanding SC 2.5.8 Target Size (Minimum) defines the 24 px floor and the five exceptions.
- A page carrying every case, served locally. Save both files in one directory, run
node server.mjs 8947, and stop it by PID afterwards, never by image name.
// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8947);
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript' };
createServer(async (req, res) => {
const path = req.url.split('?')[0];
const ext = path.slice(path.lastIndexOf('.'));
try {
const body = await readFile(new URL('.' + path, import.meta.url));
res.writeHead(200, { 'content-type': types[ext] ?? 'text/plain' });
res.end(body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
}
}).listen(port, () => console.log('serving on http://127.0.0.1:' + port + '/'));
<!-- targets.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Targets</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
button { font: inherit; border: 1px solid #888; background: #eee; }
#icon { width: 24px; height: 24px; padding: 8px; box-sizing: content-box; }
#close { width: 20px; height: 20px; padding: 0; }
#menu { width: 44px; height: 44px; padding: 0; }
.dense button { width: 20px; height: 20px; padding: 0; margin-right: 2px; }
.toolbar button { margin-right: 24px; }
p.lead { font-size: 20px; line-height: 2.4; max-width: 34em; }
p.pair a { margin: 0; }
</style>
</head>
<body>
<h1>Shipment</h1>
<div class="toolbar">
<button id="save">Save</button>
<button id="icon" aria-label="Print label"></button>
<button id="menu" aria-label="More actions"></button>
<button id="close" aria-label="Close"></button>
</div>
<p class="lead">The carrier returned a tracking code for this shipment. The label was produced by
the <a id="inline" href="/docs">v2</a> endpoint and stored with the order record, so reprinting it
does not create a second label at the carrier.</p>
<p class="pair">Filter the list by <a id="pair1" href="/a">day</a>, <a id="pair2" href="/b">week</a>.</p>
<div class="dense">
<button id="d1" aria-label="Decrease"></button><button id="d2" aria-label="Increase"></button>
</div>
</body>
</html>
- Two scripts, same directory.
// targets.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const data = await page.evaluate(() => {
const sel = 'a[href], button, input:not([type=hidden]), select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])';
const box = (e) => e.getBoundingClientRect();
const els = [...document.querySelectorAll(sel)].filter((e) => box(e).width > 0 && box(e).height > 0);
const small = els.filter((e) => box(e).width < 24 || box(e).height < 24);
const circleHitsRect = (cx, cy, r, b) => {
const nx = Math.max(b.left, Math.min(cx, b.right));
const ny = Math.max(b.top, Math.min(cy, b.bottom));
return (nx - cx) ** 2 + (ny - cy) ** 2 < r * r;
};
return els.map((e) => {
const b = box(e), cs = getComputedStyle(e);
const undersized = b.width < 24 || b.height < 24;
let spacing = '-';
if (undersized) {
const cx = (b.left + b.right) / 2, cy = (b.top + b.bottom) / 2;
const hit =
els.some((o) => o !== e && circleHitsRect(cx, cy, 12, box(o))) ||
small.some((o) => {
if (o === e) return false;
const ob = box(o);
return Math.hypot(cx - (ob.left + ob.right) / 2, cy - (ob.top + ob.bottom) / 2) < 24;
});
spacing = hit ? 'FAIL' : 'pass';
}
return {
name: `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}`,
w: b.width, h: b.height, css: `${cs.width} x ${cs.height}`, undersized, spacing,
big: b.width >= 44 && b.height >= 44,
};
});
});
console.log('element rendered box declared css 24x24 spacing 44x44');
for (const t of data) {
console.log(
t.name.padEnd(15) +
`${t.w.toFixed(1)} x ${t.h.toFixed(1)}`.padEnd(17) +
`css ${t.css}`.padEnd(23) +
(t.undersized ? 'under' : 'pass ').padEnd(8) +
t.spacing.padEnd(10) +
(t.big ? 'pass' : 'FAIL')
);
}
const under = data.filter((t) => t.undersized);
console.log(
`\n${data.length} targets under 24x24: ${under.length}` +
` of those failing the spacing exception: ${under.filter((t) => t.spacing === 'FAIL').length}` +
` under 44x44: ${data.filter((t) => !t.big).length}`
);
await browser.close();
// inline.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const [url, selector] = process.argv.slice(2);
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle2' });
const out = await page.evaluate((sel) => {
const a = document.querySelector(sel);
const b = a.getBoundingClientRect();
const cs = getComputedStyle(a);
const cx = (b.left + b.right) / 2;
const hit = (x, y) => {
const e = document.elementFromPoint(x, y);
return `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''}`;
};
return [
`${sel} text ${JSON.stringify(a.textContent)}`,
`font-size ${cs.fontSize} line-height ${cs.lineHeight} display ${cs.display} padding ${cs.padding}`,
`bounding box ${b.width.toFixed(1)} x ${b.height.toFixed(1)} client rects ${a.getClientRects().length}`,
`centre of the box (${cx.toFixed(0)}, ${((b.top + b.bottom) / 2).toFixed(0)}) hits ${hit(cx, (b.top + b.bottom) / 2)}`,
`4 px above the box (${cx.toFixed(0)}, ${(b.top - 4).toFixed(0)}) hits ${hit(cx, b.top - 4)}`,
`12 px left of the box (${(b.left - 12).toFixed(0)}, ${((b.top + b.bottom) / 2).toFixed(0)}) hits ${hit(b.left - 12, (b.top + b.bottom) / 2)}`,
].join('\n');
}, selector);
console.log(out);
await browser.close();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11. Boxes move with the font stack and the Chrome build.
Steps
- Step 1.
Measure every target, with the declared CSS beside the rendered box.
node targets.mjs http://127.0.0.1:8947/targets.htmlelement rendered box declared css 24x24 spacing 44x44 button#save 46.7 x 28.0 css 46.6719px x 28px pass - FAIL button#icon 42.0 x 42.0 css 24px x 24px pass - FAIL button#menu 44.0 x 44.0 css 44px x 44px pass - pass button#close 20.0 x 20.0 css 20px x 20px under pass FAIL a#inline 20.4 x 27.0 css auto x auto under pass FAIL a#pair1 25.3 x 21.0 css auto x auto under pass FAIL a#pair2 36.3 x 21.0 css auto x auto under pass FAIL button#d1 20.0 x 20.0 css 20px x 20px under FAIL FAIL button#d2 20.0 x 20.0 css 20px x 20px under FAIL FAIL 9 targets under 24x24: 6 of those failing the spacing exception: 2 under 44x44: 8Three lines carry the page.
button#icondeclares 24 px and renders 42, because 8 px of padding and 1 px of border sit outside a content box.a#inlineis 27 px tall and 20.4 px wide, so its height clears the floor and its width does not. Onlybutton#d1andbutton#d2fail once the spacing exception applies, and they are 20 px boxes 2 px apart. - Step 2.
Ask which part of the text line belongs to the link.
node inline.mjs http://127.0.0.1:8947/targets.html "#inline"#inline text "v2" font-size 20px line-height 48px display inline padding 0px bounding box 20.4 x 27.0 client rects 1 centre of the box (178, 250) hits a#inline 4 px above the box (178, 233) hits p 12 px left of the box (155, 250) hits pThe visible row is 48 px tall and the link owns 27 of them.
elementFromPointconfirms it: 4 px above the box the paragraph answers, not the link. An inline element takes its box height from font metrics, so raisingline-heightmakes the row roomier and the target no larger. Padding widens the box without moving the line, which is the usual fix. - Step 3.
Run the same measurement on a site nobody wrote for this page.
node targets.mjs https://www.cloudflare.com/… a 89.1 x 16.0 css auto x auto under pass FAIL a 136.7 x 16.0 css auto x auto under pass FAIL a 77.6 x 16.0 css auto x auto under pass FAIL a 64.7 x 16.0 css auto x auto under pass FAIL button 163.0 x 17.5 css 163.047px x 17.5px under pass FAIL 98 targets under 24x24: 12 of those failing the spacing exception: 0 under 44x44: 78Read the summary before the rows. 12 targets of 98 are under 24 by 24 and every one clears the spacing exception, so the page meets 2.5.8 on this reading. 78 targets are under 44 by 44, which is the AAA criterion. A checker that reports only the 44 px number turns a passing page into a 78-line defect list.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| under with spacing pass | Undersized, and the spacing exception covers it | No change required for 2.5.8. Confirm nothing moves next to it on narrow layouts |
| under with spacing FAIL | Two small targets are within 24 px of each other | Add margin until the centres are 24 px apart, or grow the boxes |
| Declared css far below the rendered box | Padding or border is doing the work | Measure, never grep the stylesheet. The rendered box is the target |
| css auto x auto on a link | An inline element, sized by font metrics | line-height will not help. Add padding, or make it inline-block |
| 44x44 FAIL on most of a page | The page is being read against 2.5.5, the AAA criterion | Report 2.5.8 as the pass or fail, and 2.5.5 as an improvement |
Common mistakes
Thresholds
Level AA in WCAG 2.2. Undersized targets still conform when a 24 px diameter circle centred on each touches no other target and no other such circle. That is the spacing column in step 1.
Source: W3C, Understanding SC 2.5.8 Target Size (Minimum), https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.htmlLevel AAA. Report it separately from 2.5.8: a page can meet the AA criterion with most of its targets below this number.
Source: W3C, Understanding SC 2.5.5 Target Size (Enhanced), https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.htmlWhat to check next
- How to test a page at 200 percent zoom: reflow moves targets, so measure again at 320 CSS px.
- How to check viewport meta tag: a page that blocks zoom leaves the small target as the only option.
- How to check visible focus indicator: the same controls on the keyboard path.
- How to check accessibility tree: confirm each measured box is exposed as a control.
- How to test prefers reduced motion: targets that move under the finger fail separately.
FAQ
What is the minimum tap target size?
24 by 24 CSS pixels, from WCAG 2.2 SC 2.5.8 at level AA, with exceptions for spacing, inline targets, equivalent controls, user agent defaults and essential presentation. 44 by 44 belongs to SC 2.5.5 at level AAA. Report the two separately.
Is there a tap target size checker?
Lighthouse reports tap targets in its mobile audit, and the script in step 1 gives the same measurement with the declared CSS and the spacing test beside it. A checker that prints one number without naming the criterion and its exceptions produces a list you cannot act on.
How do I check touch target size on a real device?
Both criteria are written in CSS pixels, so device pixel ratio does not enter it. Run the script at the viewport width the device reports, then confirm on hardware that nothing overlaps.
Does an inline link inside a paragraph have to be 24 by 24?
SC 2.5.8 exempts a target positioned in a sentence, or one whose size is constrained by the line height of surrounding text. The link in step 2 also clears the spacing exception. Both routes conform, and the measurement still belongs in the report, since a change to the paragraph can remove either one.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0
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
intermediate8 minpublished updated Maks Verny