"Tap Targets Are Not Sized Appropriately": How to Fix It
Fix "tap targets are not sized appropriately": the real thresholds (24px WCAG AA, 44–48px advice), CSS fixes, and data: 44% of homepages fail WCAG 2.5.8.
"Tap targets are not sized appropriately" means links, buttons or form controls on your mobile page are too small, or too close together, to tap reliably with a finger. Fix it by making each control at least 24×24 CSS pixels (the WCAG 2.2 AA minimum), or by spacing small controls so a 24px circle centred on each doesn't touch its neighbours. Aim for 44–48px wherever the layout allows, mostly by adding padding rather than enlarging text.
The message itself is an older one. It was the title of Lighthouse's SEO tap-targets audit, which used a 48×48px rule, and of a similar issue in Search Console's Mobile Usability report. Google retired that report on 1 December 2023, and Lighthouse 12.0 (April 2024) replaced the SEO audit with an accessibility check based on WCAG's 24px rule. The problem didn't go away. On 9 October 2026, 43.6% of 188 random mobile homepages had at least one target failing WCAG 2.5.8.
This guide explains the real thresholds, what our data says about which controls fail, CSS fixes for each, and a script that lists failures with the padding each one needs.
The Real Thresholds (and the 48px Myth)
Different sources give different numbers, and they measure different things:
| Source | Size | Status | Notes |
|---|---|---|---|
| WCAG 2.2 SC 2.5.8 Target Size (Minimum) | 24×24 CSS px | Level AA, the legal/compliance baseline | Smaller targets pass if spaced so a 24px circle on each doesn't overlap another target or its circle. Inline links in text are exempt |
| WCAG 2.2 SC 2.5.5 Target Size (Enhanced) | 44×44 CSS px | Level AAA | Rarely required, a good aim |
| Apple Human Interface Guidelines | 44×44 pt | Platform recommendation | pt ≈ CSS px in mobile Safari |
| Android / Material guidance | 48×48 dp | Platform recommendation | dp ≈ CSS px in mobile Chrome |
| Old Lighthouse SEO "tap-targets" audit | 48×48 px, with an overlap rule | Removed in Lighthouse 12.0 | The source of the "48px minimum" myth |
So "48px minimum" isn't a rule. It's a recommendation from Android's guidance that an old SEO audit enforced. The requirement you can be held to is WCAG 2.5.8 at 24px, and the official Understanding 2.5.8 document explains the spacing exception with diagrams. Apple's accessibility guidelines and Google's Android touch target guidance give the 44pt and 48dp recommendations.
Five exceptions in 2.5.8 matter in practice:
- Spacing: an undersized target passes if nothing else is within its 24px circle.
- Inline: links inside a sentence or paragraph are sized by the line of text and are exempt.
- Equivalent: another control on the same page does the same thing at an adequate size.
- User agent control: the size is set by the browser and not changed by the author (a default checkbox).
- Essential: the size is legally or functionally required (rare, such as map pins).
What We Found on 188 Mobile Homepages
We loaded the 188 homepages from our website accessibility statistics sample (random, Tranco ranks 1,001–50,000) in Chromium on BugViso's iPhone 16 Pro profile (390×844) and ran BugViso's own target-size check. It skips hidden, invisible and inline-in-text links and applies the 24px spacing exception as axe-core does.
| Measurement | Result |
|---|---|
| Homepages with ≥1 target failing WCAG 2.5.8 | 82 of 188 (43.6%) |
| Homepages with ≥1 target under 24px (before the spacing exception) | 170 (90.4%) |
| Undersized targets rescued by the spacing exception | 74.8% |
| Median failing targets per failing homepage | 7 |
| Homepages with targets between 24px and 44px (pass AA, under 44px) | 135 (71.8%) |
| Homepages with touch-target collisions (small targets < 8px apart) | 113 (60.1%) |
This is higher than the 24.7% that axe-core flagged on the same sites in our WCAG 2.2 checklist study, because that run used a 1366px desktop viewport. On a 390px phone, navigation collapses into stacked lists and controls sit closer together, so more small targets lose the spacing exception.
Two things stand out. Almost every site has some small targets, but three-quarters of them pass because they're well spaced, so spacing is as much of a fix as size. And the failures are usually near misses:
| Smaller side of failing targets | Share of 1,263 sampled failures |
|---|---|
| 20–23px | 35.9% |
| 16–19px | 44.3% |
| 12–15px | 16.6% |
| Under 12px | 3.2% |
The median failing target was 70×18px: wide enough, just not tall enough. That's a text link or button with no vertical padding. 80% of failures are within 8px of passing, so 4px of padding top and bottom fixes most of them.
Which controls fail (share of failing targets):
| Control type | Share of failures | Homepages affected |
|---|---|---|
| Standalone text links and buttons (in lists, cards, toolbars) | 34.5% | 47 |
| Navigation and header links | 31.3% | 13 |
| Footer links | 17.9% | 26 |
| Icon-only controls (close, share, social, menu) | 12.9% | 20 |
| Carousel dots and pagers | 3.1% | 11 |
| Form fields | 0.4% | 4 |
Navigation links make up nearly a third of failures but come from only 13 sites. One dense mega-menu or link-heavy header can fail dozens of targets at once. Footer link lists and icon buttons fail on many more sites.
How to Fix Each Pattern
Stacked text links (footer, sidebars, link lists)
/* ❌ 14px text, line-height 1.2: each link is ~17px tall and touches the next */
.footer-links a { font-size: 14px; line-height: 1.2; }
/* ✅ Same text size, padded to a 44px hit area; the visual size barely changes */
.footer-links a {
display: inline-block; /* padding on inline elements doesn't affect layout */
padding-block: 12px;
line-height: 20px; /* 12 + 20 + 12 = 44px */
}
.footer-links li + li { margin-top: 0; } /* the padding provides the spacing */The key line is display: inline-block (or block/flex). Vertical padding on a plain inline <a> paints but doesn't push neighbours apart, so the targets still overlap.
Icon-only buttons
/* ❌ The button is exactly the size of its 16px icon */
.icon-btn { width: 16px; height: 16px; padding: 0; }
/* ✅ Icon stays 16px; the button becomes a 44px hit area */
.icon-btn {
inline-size: 44px; block-size: 44px;
display: inline-grid; place-items: center;
padding: 0;
}
.icon-btn svg { inline-size: 16px; block-size: 16px; }Every icon-only button also needs an accessible name (aria-label="Close"). Small icon buttons are often also unnamed, and axe flags both.
When you can't make it bigger: enlarge the hit area only
/* ✅ Invisible 24px+ hit area around a small visual control */
.tiny-control { position: relative; }
.tiny-control::after {
content: ""; position: absolute;
inset: 50% auto auto 50%;
inline-size: 44px; block-size: 44px;
transform: translate(-50%, -50%);
}The pseudo-element is part of the element, so taps on it count as taps on the control. Make sure enlarged hit areas don't overlap each other, or you've only moved the problem.
Carousel dots
/* ✅ 10px visual dots inside 24px (or larger) buttons, with gaps */
.carousel-dots { display: flex; gap: 8px; }
.carousel-dots button { inline-size: 24px; block-size: 24px; padding: 7px; border-radius: 50%; }
.carousel-dots button::before { content: ""; display: block; inline-size: 10px; block-size: 10px; border-radius: 50%; background: currentColor; }Navigation and menus
On mobile, switch dense horizontal link rows to a vertical menu with full-width rows of at least 44px. If a row of small links must stay (breadcrumbs, language switcher), use gap: 12px or more between them so the spacing exception applies.
Spacing instead of size
/* ✅ 20px-high tags pass WCAG 2.5.8 via spacing: centres are 24px+ from each other's edges */
.tag-list { display: flex; flex-wrap: wrap; gap: 14px; }
.tag-list a { padding: 2px 8px; line-height: 16px; }For the spacing exception, measure from the centre of a small target to the nearest edge of its neighbours. It must be at least 12px (half of 24). That's the rule BugViso and axe-core apply.
Find Every Failing Target: Script
This Playwright script loads a page on a phone-sized viewport, applies the same WCAG 2.5.8 logic (size, spacing exception and inline-link exemption), and prints each failing target with its size, its nearest neighbour and the padding that would make it pass.
#!/usr/bin/env python3
"""tap_targets.py: list touch targets that fail WCAG 2.2 SC 2.5.8 (24x24 CSS px) on a phone viewport.
Usage:
pip install playwright && playwright install chromium
python3 tap_targets.py https://example.com [--width 390] [--all]
A target fails only when it is smaller than 24x24 AND a 24px circle centred on it would reach
another target (the spacing exception), which is how WCAG and axe-core's target-size rule treat it.
Inline links inside a sentence are exempt. For each failure it prints the size, the nearest
neighbour distance and the padding that would make it pass. --all also lists 24-43px targets
(passing AA, but under the 44px AAA / platform recommendation).
"""
import argparse, asyncio
from playwright.async_api import async_playwright
JS = r"""(all) => {
const sel = 'a, button, input:not([type="hidden"]), select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="tab"]';
const label = (el) => (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('title') || '').replace(/\s+/g, ' ').trim().slice(0, 30) ||
el.tagName.toLowerCase() + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/)[0] : '');
const inlineText = (el) => el.tagName === 'A' && getComputedStyle(el).display === 'inline' &&
[...el.parentElement.childNodes].some(n => n !== el && n.nodeType === 3 && n.textContent.trim());
const hidden = (el, r) => { if (r.width <= 2 || r.height <= 2) return true;
for (let n = el, d = 0; n && n.nodeType === 1 && d < 25; n = n.parentElement, d++) {
const s = getComputedStyle(n); if (s.visibility === 'hidden' || +s.opacity === 0 || s.pointerEvents === 'none') return true; }
return false; };
const T = [];
for (const el of document.querySelectorAll(sel)) {
if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') continue;
const r = el.getBoundingClientRect();
if (!r.width || !r.height || inlineText(el) || hidden(el, r)) continue;
T.push({el, r, cx: r.left + r.width / 2, cy: r.top + r.height / 2});
}
const dist = (x, y, r) => Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
const out = [];
for (const t of T) {
const {r} = t;
const near = Math.min(...T.filter(o => o !== t).map(o => dist(t.cx, t.cy, o.r)), 999);
const small = r.width < 24 || r.height < 24;
if (small && near < 12) out.push({kind: 'FAIL', what: label(t.el), w: Math.round(r.width), h: Math.round(r.height), near: Math.round(near),
fix: `padding ≥ ${Math.ceil(Math.max(0, 24 - r.height) / 2)}px vertical, ${Math.ceil(Math.max(0, 24 - r.width) / 2)}px horizontal`});
else if (all && !small && r.width < 44 && r.height < 44) out.push({kind: 'below 44', what: label(t.el), w: Math.round(r.width), h: Math.round(r.height), near: Math.round(near), fix: ''});
}
return {total: T.length, rows: out};
}"""
async def main(url, width, show_all):
async with async_playwright() as pw:
browser = await pw.chromium.launch()
ctx = await browser.new_context(viewport={"width": width, "height": 844}, device_scale_factor=3,
is_mobile=True, has_touch=True)
page = await ctx.new_page()
await page.goto(url, wait_until="load", timeout=45000)
await page.wait_for_timeout(1500)
res = await page.evaluate(JS, show_all)
await browser.close()
fails = [r for r in res["rows"] if r["kind"] == "FAIL"]
print(f"{url} at {width}px: {res['total']} targets, {len(fails)} fail WCAG 2.5.8")
for r in res["rows"]:
print(f" {r['kind']:8s} {r['w']:>3}x{r['h']:<3} nearest {r['near']:>3}px {r['what'][:30]:30s} {r['fix']}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("url")
ap.add_argument("--width", type=int, default=390)
ap.add_argument("--all", action="store_true")
a = ap.parse_args()
asyncio.run(main(a.url, a.width, a.all))Real output for a homepage from our sample (9 October 2026, domain replaced):
https://site-search.example/ at 390px: 27 targets, 8 fail WCAG 2.5.8
FAIL 193x22 nearest 7px text to search for padding ≥ 1px vertical, 0px horizontal
FAIL 57x22 nearest 0px Search padding ≥ 1px vertical, 0px horizontal
FAIL 89x20 nearest 0px All Documentation FAQ & Refere padding ≥ 2px vertical, 0px horizontal
FAIL 53x16 nearest 0px features padding ≥ 4px vertical, 0px horizontal
FAIL 43x16 nearest 0px pricing padding ≥ 4px vertical, 0px horizontal
FAIL 20x16 nearest 0px faq padding ≥ 4px vertical, 2px horizontal
FAIL 40x16 nearest 0px library padding ≥ 4px vertical, 0px horizontal
FAIL 55x16 nearest 0px site map padding ≥ 4px vertical, 0px horizontalThis is the pattern from the data: a row of 16px-tall text links touching each other, each 4px of vertical padding away from passing. One CSS rule on the nav fixes all of them. If you also want the 44px recommendation, re-run with --all.
How BugViso Checks Tap Targets
BugViso's mobile pass loads every scanned page in Chromium as an iPhone 16 Pro (390×844, DPR 3, touch) and measures every visible link, button, input, select, textarea and role="button" element. It reports:
- Targets failing WCAG 2.2 SC 2.5.8 (AA): under 24×24 CSS px and without the 12px centre-to-edge spacing, with selector and size samples. It's the same logic as the script and axe-core's target-size rule.
- Targets passing AA but under 44×44, reported separately as advice, not as a failure.
- Touch-target collisions: small targets less than 8px apart, with the pairs involved.
- Exclusions that prevent false positives: inline links in sentences, screen-reader-only skip links, and invisible or
pointer-events: nonecontrols such as inactive tab panels.
The same pass checks the viewport meta tag and horizontal overflow, and the full axe-core WCAG run covers the accessible-name problems that usually come with small icon buttons. You can check your tap targets with a free BugViso scan. For the rest of the WCAG 2.2 changes, see the WCAG 2.2 checklist, and the WCAG accessibility and mobile checks page lists everything in the mobile pass.
Traps and Edge Cases
- Visual size isn't hit-area size. A 44px-tall row with a 16px-tall link inside it still fails if only the link is clickable. Make the link fill the row (
display: block). - Line-height makes neighbours touch. Two stacked links with
line-height: 1and no margin have 0px spacing, so the spacing exception can't save them. - Zoom doesn't fix it. "Users can pinch-zoom" isn't an exception, and 15.1% of homepages in our sample blocked zoom anyway with
user-scalable=noormaximum-scale=1. - Hover-revealed controls. Controls that only appear on hover have no reliable equivalent on touch, so make them visible or tappable without hover.
- Fixed headers shrink on scroll. A header that collapses from 64px to 40px can push its links under 24px. Test the scrolled state too.
FAQ
What does "tap targets are not sized appropriately" mean?
Interactive elements are too small or too close together to tap accurately. The message came from an older Google audit that used a 48px rule. Today the accessibility standard is WCAG 2.2's 24×24px minimum with a spacing exception.
What is the minimum tap target size?
24×24 CSS pixels for WCAG 2.2 Level AA (SC 2.5.8), unless the target is spaced well enough. 44×44 is Level AAA and Apple's recommendation, and 48×48dp is Android's.
Is 48px required for SEO?
No. Google retired its Mobile Usability report in December 2023, and the 48px SEO audit was removed from Lighthouse in version 12.0. Small targets still hurt usability and accessibility, and WCAG 2.5.8's 24px rule is the standard to meet.
Do inline text links need to be 24px?
No. Links inside a sentence or paragraph are exempt under WCAG 2.5.8, because the line of text sets their size. Standalone links in lists, nav and footers aren't exempt.
How do I fix tap targets without changing my design?
Add padding (with display: inline-block or block) or an invisible ::after hit area, and add spacing between small controls. The visible size can stay the same while the tappable area grows.
Conclusion
Most failing tap targets are text links that are wide enough but 4–8px too short: give them vertical padding as block-level boxes, space the rest, and size icon buttons at 44px. A BugViso scan lists every target under WCAG's 24px minimum on its mobile pass.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.