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

// 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>
// 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();

Steps

  1. Step 1.

    Measure every target, with the declared CSS beside the rendered box.

    node targets.mjs http://127.0.0.1:8947/targets.html
    
    element        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: 8

    Three lines carry the page. button#icon declares 24 px and renders 42, because 8 px of padding and 1 px of border sit outside a content box. a#inline is 27 px tall and 20.4 px wide, so its height clears the floor and its width does not. Only button#d1 and button#d2 fail once the spacing exception applies, and they are 20 px boxes 2 px apart.

  2. 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 p

    The visible row is 48 px tall and the link owns 27 of them. elementFromPoint confirms it: 4 px above the box the paragraph answers, not the link. An inline element takes its box height from font metrics, so raising line-height makes the row roomier and the target no larger. Padding widens the box without moving the line, which is the usual fix.

  3. 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: 78

    Read 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

Sign: Target size is audited by searching the stylesheet for width and height rules.Cause: On the capture above, button#icon declares width 24px and renders 42 by 42, because box-sizing is content-box and 8 px of padding plus 1 px of border sit outside it. The declared number and the target were different on the one element where it mattered.
Sign: An inline link is assumed to be as tall as the line it sits on.Cause: The paragraph has line-height 48px and the link box measures 27.0 px tall. elementFromPoint 4 px above the box returns the paragraph. Height on an inline non-replaced element comes from font metrics, so line-height changes the row spacing and not the hit area.
Sign: Every target under 24 by 24 is filed as a defect.Cause: Six targets on the test page are undersized and four of them pass through the spacing exception. Cloudflare's home page had 12 undersized targets and zero spacing failures. Size alone is not the verdict, and a report that skips the exception sends developers to fix targets that already conform.
Sign: 44 by 44 is quoted as the requirement.Cause: 44 by 44 is SC 2.5.5 Target Size (Enhanced), level AAA. The AA criterion added in WCAG 2.2 is 2.5.8 at 24 by 24. On the capture above, 78 of 98 targets were under 44 and the page still met 2.5.8.

Thresholds

24 by 24 CSS px

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.html
44 by 44 CSS px

Level 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.html

What to check next

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.

intermediate8 minpublished updated Maks Verny