"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.

BugViso

14 min read

"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:

SourceSizeStatusNotes
WCAG 2.2 SC 2.5.8 Target Size (Minimum)24×24 CSS pxLevel AA, the legal/compliance baselineSmaller 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 pxLevel AAARarely required, a good aim
Apple Human Interface Guidelines44×44 ptPlatform recommendationpt ≈ CSS px in mobile Safari
Android / Material guidance48×48 dpPlatform recommendationdp ≈ CSS px in mobile Chrome
Old Lighthouse SEO "tap-targets" audit48×48 px, with an overlap ruleRemoved in Lighthouse 12.0The 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:

  1. Spacing: an undersized target passes if nothing else is within its 24px circle.
  2. Inline: links inside a sentence or paragraph are sized by the line of text and are exempt.
  3. Equivalent: another control on the same page does the same thing at an adequate size.
  4. User agent control: the size is set by the browser and not changed by the author (a default checkbox).
  5. 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.

MeasurementResult
Homepages with ≥1 target failing WCAG 2.5.882 of 188 (43.6%)
Homepages with ≥1 target under 24px (before the spacing exception)170 (90.4%)
Undersized targets rescued by the spacing exception74.8%
Median failing targets per failing homepage7
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 targetsShare of 1,263 sampled failures
20–23px35.9%
16–19px44.3%
12–15px16.6%
Under 12px3.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 typeShare of failuresHomepages affected
Standalone text links and buttons (in lists, cards, toolbars)34.5%47
Navigation and header links31.3%13
Footer links17.9%26
Icon-only controls (close, share, social, menu)12.9%20
Carousel dots and pagers3.1%11
Form fields0.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

css
/* ❌ 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

css
/* ❌ 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

css
/* ✅ 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.

css
/* ✅ 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; }

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

css
/* ✅ 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.

python
#!/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):

text
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 horizontal

This 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: none controls 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: 1 and 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=no or maximum-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.

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.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.