WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Mobile UI in React
  4. Minimum Touch Target Size: WCAG, iOS, Android and Tailwind
  1. Blog
  2. Mobile UI in React
  3. Minimum Touch Target Size: WCAG, iOS, Android and Tailwind
Mobile UI in React
Mobile UI in React

Minimum Touch Target Size: WCAG, iOS, Android and Tailwind

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. What is the minimum touch target size?
  2. What does WCAG 2.2 require for touch targets?
  3. What is the minimum touch target size iOS and Android recommend?
  4. Which size should a React web app use?
  5. How do you hit 44px on touch without bloating the desktop UI?
    1. Grow the control when the layout has room
    2. Add an invisible hit area when the control must look small
    3. Make the label the target
  6. Should you use pointer: coarse or any-pointer: coarse?
  7. When should you detect touch devices in React?
  8. How do you test touch targets?
  9. Where should you start?
  10. FAQ
    1. What is the minimum touch target size in WCAG 2.2?
    2. What is the minimum touch target size on iOS?
    3. Is a 44px touch target enough for Android?
    4. Does an invisible hit area count toward the target size?
    5. What is the pointer coarse media query?
    6. Do Wingo UI components meet 44px on touch screens?

TL;DR

The minimum touch target size is 24 by 24 CSS pixels for WCAG 2.2 level AA, 44 by 44 for level AAA and for Apple's default iOS control, and 48 by 48 dp in Material and Android guidance. For a React web app, aim for 44px under the pointer: coarse media query and keep compact sizes for a mouse. In Tailwind CSS v4.1 and later that means pointer-coarse:h-11 on controls with room to grow and an invisible ::after hit area on icons and checkboxes that must stay small.

Published Oct 9, 2026

Ask what the minimum touch target size is and you get four numbers: 24, 28, 44 and 48. They come from different documents, in different units, and two of them are floors that nobody should design to. This tutorial sorts out what WCAG 2.2, Apple and Android ask for, picks one number for a React web app, and builds it in Tailwind CSS v4.1: buttons, icon buttons and checkboxes that take 44px taps on a phone while the desktop keeps its 32px and 40px controls. It ends with a Playwright test that fails when a target shrinks.

What is the minimum touch target size?

The minimum touch target size is 24 by 24 CSS pixels for WCAG 2.2 level AA, 44 by 44 for WCAG level AAA and for Apple's default iOS control, and 48 by 48 dp for Material and Android. The rest of each document is about exceptions and spacing:

Source (as of October 2026)Target sizeSpacingStatus
WCAG 2.2, SC 2.5.8 Target Size (Minimum) (opens in a new tab)24 by 24 CSS pxsmaller targets pass when spaced outLevel AA
WCAG 2.2, SC 2.5.5 Target Size (Enhanced) (opens in a new tab)44 by 44 CSS pxnoneLevel AAA
Apple HIG, iOS and iPadOS (opens in a new tab)44 by 44 pt default, 28 by 28 pt minimumabout 12 pt around bezeled controls, 24 pt around borderless onesGuideline
Apple HIG, macOS (opens in a new tab)28 by 28 pt default, 20 by 20 pt minimumsameGuideline
Android accessibility help (opens in a new tab)48 by 48 dp, about 9 mm8 dp or moreRecommendation

The units are closer than they look. With the width=device-width viewport, which the Next.js App Router adds by default, one CSS pixel is one point on an iPhone and one dp on Android. So 44px in your CSS is Apple's 44 pt, and 48px is Material's 48 dp.

Two details matter more than the numbers. WCAG measures the area that accepts the tap, which it defines as the "region of the display that will accept a pointer action", so padding counts and a small icon can still pass. And WCAG covers every pointer, mouse included, so the 24px floor applies to your desktop layout too.

What does WCAG 2.2 require for touch targets?

The minimum touch target size WCAG 2.2 sets at level AA is 24 by 24 CSS pixels, unless one of five exceptions applies. The spec (opens in a new tab) lists them:

  • Spacing. An undersized target passes if a 24px circle centered on its bounding box does not intersect another target or the circle of another undersized target. In practice: undersized targets need their centers at least 24px apart, and at least 12px from the edge of any other target.
  • Equivalent. Another control on the same page does the same thing and meets the size.
  • Inline. The target sits in a sentence, like a link in a paragraph.
  • User agent control. The browser sizes the control and you did not restyle it.
  • Essential. That presentation is essential or legally required.

Level AAA (2.5.5) asks for 44 by 44 with the same exceptions minus spacing. WCAG's definition of a target adds a note that matters for the techniques below: where targets overlap, the overlapping area does not count toward their size, unless they perform the same action or open the same page.

Treat AA as an audit floor. It lets a 24px icon pass, and Apple and Google ask for about twice that on touch.

What is the minimum touch target size iOS and Android recommend?

Apple lists 44 by 44 points as the default control size on iOS and iPadOS and 28 by 28 points as the minimum. Google's Android guidance asks for 48 by 48 dp with at least 8 dp between targets, which it puts at about 9 mm.

Apple also says to "consider spacing between controls as important as size" and suggests about 12 points of padding around controls with a bezel and about 24 around controls without one. Native Android does part of the work for you: the Compose reference (opens in a new tab) calls 48 dp "the Material recommended minimum size" and says the system expands touch targets at the input layer. On the web, build that area yourself, in CSS.

Which size should a React web app use?

Use 44px on coarse pointers, keep 8px between neighboring targets, never go below 24px for any pointer, and give the main action of a phone screen 48px. A 44px touch target meets WCAG AAA and Apple's default in one number. If your analytics say most of your users are on Android, swap 44 for 48: pointer-coarse:h-12 on buttons and 48px in the hit-area utility below.

The desktop is where teams overcorrect. Apple's own default for a macOS control is 28 points, and 44px controls everywhere make a settings form or a table toolbar look like a kiosk. Size by input device instead of by screen width.

How do you hit 44px on touch without bloating the desktop UI?

Switch sizes on the pointer coarse media query, @media (pointer: coarse), which matches when the primary pointer is a finger. Tailwind CSS v4.1 added it as the pointer-coarse variant (opens in a new tab). A width breakpoint gets this wrong twice: an iPad in landscape is wider than lg and gets mouse-sized controls, and a narrow desktop window gets thumb-sized ones.

There are three techniques, in order of preference.

Grow the control when the layout has room

A text button can afford 4 extra pixels of height. This is the size map of the Button in Wingo UI:

tsx
// components/ui/button.tsx (excerpt): 32 / 40 / 48px
const SIZES: Record<ButtonSize, string> = {
sm: "h-8 gap-1.5 px-3 text-[13px] pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5",
md: "h-10 gap-2 px-4 text-sm pointer-coarse:h-11",
lg: "h-12 gap-2 px-5 text-[15px]",
"icon-sm": "size-8 pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5",
icon: "size-10 pointer-coarse:size-11",
"icon-lg": "size-12",
};

The default md is 40px with a mouse and 44px under a finger, and so is the square icon size. lg is 48px everywhere, which suits a full-width submit button at the bottom of a phone screen. Since the class is a media query, the server-rendered HTML is already right on the first paint.

Add an invisible hit area when the control must look small

A 32px icon in a toolbar should stay 32px. Give it a pseudo-element that extends past its edges on touch: (44 - 32) / 2 = 6px on each side, which is -inset-1.5. That is what sm and icon-sm do above; sm only extends vertically, because a text button is usually wider than 44px already. In Tailwind v4 the after: variant sets content for you and the button is already relative (its ::before holds the hover tint).

For your own components, one utility can do the math for any size:

css
/* app/globals.css */
/* a 44px hit area around any smaller control; the control needs a position */
@utility hit-area {
&::after {
content: "";
position: absolute;
inset-block: min(0px, calc((100% - 44px) / 2));
inset-inline: min(0px, calc((100% - 44px) / 2));
}
}
tsx
import { X } from "lucide-react";
export function DismissButton({ onDismiss }: { onDismiss: () => void }) {
return (
<button
type="button"
aria-label="Dismiss"
onClick={onDismiss}
className="relative grid size-7 touch-manipulation place-items-center rounded-full [-webkit-tap-highlight-color:transparent] pointer-coarse:hit-area"
>
<X className="size-4" />
</button>
);
}

The percentages resolve against the control itself, and min(0px, ...) never shrinks a control that is already big enough. We measured it in Chromium: a 16px and a 32px button both take taps on exactly 44 by 44, an 80 by 32 button on 80 by 44, and a 60px button keeps its 60px.

Hit areas fail in three ways, all invisible until someone misses a tap:

  1. Clipping. overflow-hidden on the control cuts its own ::after, and an ancestor with overflow-hidden cuts it at the ancestor's edge. Put clipping on an inner layer instead. The Haptic Button clips its ripple and hold fill on an inner layer, so the button itself keeps its 44px area.
  2. Overlap. Two 32px icons 4px apart have 44px areas that overlap by 8px. The later one in the DOM wins every tap in the overlap, and WCAG does not count it. Keep centers 44px apart on touch: gap-1 pointer-coarse:gap-3 for 32px icons.
  3. No position. Without relative on the control, the ::after sizes itself against the nearest positioned ancestor, and that whole ancestor becomes an invisible target for this one control.

Make the label the target

For checkboxes, radios and switches, the box can stay 16 to 20px when the whole row takes the tap. The Checkbox renders the row as one <label>. On a coarse pointer the row is at least 44px tall (pointer-coarse:min-h-11) and full width, and in a group the gap between rows drops to zero while the padding moves inside each row, so a tap between two options always lands on one of them. A box without a label, in a table cell for example, gets the ::after treatment instead: the Checkbox adds 14px on each side of its 16px and 18px boxes and 12px around the 20px one.

Tap the empty space to the right of a label on a phone: the whole row toggles the box. With a mouse, the rows shrink back to the box and its text
$ npx wingo-ui@latest add checkbox
FreeCheckbox docs

The same idea works with a native input. min-h-6 keeps the row at the 24px floor for a mouse:

tsx
export function ReleaseEmails() {
return (
<label className="flex min-h-6 w-fit cursor-pointer touch-manipulation items-center gap-3 text-sm pointer-coarse:min-h-11 pointer-coarse:w-full">
<input type="checkbox" name="releases" className="size-4" />
Email me when a new version ships
</label>
);
}

Should you use pointer: coarse or any-pointer: coarse?

Use pointer: coarse for sizes in most apps. It describes the primary input, so a phone or tablet matches and a laptop does not. any-pointer: coarse (opens in a new tab) matches when at least one connected input is coarse, and MDN notes that more than one value can match at once. A laptop with a touchscreen usually has a fine primary pointer, so it matches any-pointer: coarse and not pointer: coarse.

Pick any-pointer when touch is a common second input, as on convertible laptops and touchscreen all-in-one desktops; the cost is larger controls while someone uses the trackpad. To keep that decision in one place, give it a name:

css
/* app/globals.css: every touch:* class follows this one line */
@custom-variant touch (@media (pointer: coarse));
/* include touchscreen laptops instead: */
/* @custom-variant touch (@media (any-pointer: coarse)); */

Then write touch:h-11 and touch:hit-area, and switch the definition in one line later. Wingo UI uses pointer: coarse; the useMediaQuery hook exports it as TOUCH_QUERY, so components and app code agree on what touch means.

Your device answers live: useIsTouch() is pointer: coarse, the hover card needs a mouse or trackpad, and the last card runs any-pointer: coarse
$ npx wingo-ui@latest add use-media-query
FreeuseMediaQuery docs

When should you detect touch devices in React?

Detect touch in React only for behavior, never for size. The server cannot know the pointer, so useIsTouch() returns false on the server and during hydration. A size picked by the hook renders desktop controls first and jumps on phones; the CSS variant is right from the first paint. The mobile-first React components guide covers the same split for layout, and the SSR-safe React use mobile hook tutorial explains why these hooks return false on the server.

Use it in effects and event handlers. Autofocus is the classic case, because focusing an input on a phone opens the keyboard over half the screen. This snippet uses the free hook, installed with npx wingo-ui@latest add use-media-query:

tsx
"use client";
import { useEffect, useRef } from "react";
import { matchesMedia, TOUCH_QUERY } from "@/hooks/use-media-query";
export function ClientSearch() {
const ref = useRef<HTMLInputElement>(null);
// on touch, focus would open the keyboard before anyone asked for it
useEffect(() => {
if (!matchesMedia(TOUCH_QUERY)) ref.current?.focus();
}, []);
return (
<input
ref={ref}
type="search"
name="q"
placeholder="Search clients"
className="h-10 w-full rounded-[10px] border border-border bg-transparent px-3 text-base pointer-coarse:h-11 sm:text-sm"
/>
);
}

For decisions per interaction, read event.pointerType instead of a media query, because a touchscreen laptop can send both kinds of events. The Haptic Button plays its haptic tick only for touch and pen presses; a mouse click never vibrates.

How do you test touch targets?

Run axe for the 24px floor and a hit test at 44px with touch emulation, then try the screen with your thumb on a real phone.

axe-core's target-size rule checks WCAG 2.5.8. In axe-core 4.13 it is off by default and runs when you ask for the wcag22aa tag, for example new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]) with @axe-core/playwright. It tests the AA floor, so a 32px icon button with no hit area passes it.

For 44px, check what the browser actually hits. In Chromium, Playwright's hasTouch: true makes (pointer: coarse) match and (hover: hover) stop matching, so your pointer-coarse: classes apply. document.elementFromPoint at the corners of a 44px square around each control then catches all three failures above, because hit testing includes ::after, respects overflow clipping and returns the topmost of two overlapping targets:

ts
// e2e/touch-targets.spec.ts
import { expect, test } from "@playwright/test";
const MIN = 44;
const CONTROLS =
'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="checkbox"], [role="switch"], [role="tab"]';
// hasTouch makes (pointer: coarse) match, so pointer-coarse: styles apply
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
test("every control takes 44px taps on touch", async ({ page }) => {
// any page of your app; a relative URL needs baseURL in playwright.config.ts
await page.goto("/settings");
const misses = await page.evaluate(
({ selector, min }) => {
const half = min / 2 - 1;
const found: string[] = [];
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
// links inside running text are exempt (the inline exception)
if (el.matches("a") && el.closest("p, label")) continue;
if (el.closest("[aria-hidden='true'], [inert]")) continue;
// a control inside a <label> is as big as the label
const target = el.closest("label") ?? el;
target.scrollIntoView({ block: "center" });
const box = target.getBoundingClientRect();
if (!box.width || !box.height) continue;
const x = box.left + box.width / 2;
const y = box.top + box.height / 2;
const corners = [[-half, -half], [half, -half], [-half, half], [half, half]];
// hit testing sees ::after hit areas, overflow clipping and overlapping neighbors
const ok = corners.every(([dx, dy]) => target.contains(document.elementFromPoint(x + dx, y + dy)));
if (!ok) found.push(`${Math.round(box.width)}x${Math.round(box.height)} ${el.outerHTML.slice(0, 80)}`);
}
return found;
},
{ selector: CONTROLS, min: MIN },
);
expect(misses).toEqual([]);
});

With Playwright 1.63 the Checkbox demo and the icon-sm Button pass it, while a bare 32px button and one inside an overflow-hidden wrapper are both reported.

Emulation proves the CSS. It does not prove the layout works one-handed, so finish on a phone: reach for the top corners with the hand that holds it, and tap the controls you placed close together.

Where should you start?

Install the free Button with npx wingo-ui@latest add button and open your app on a phone: every md button is 44px there and 40px with a mouse, and the small sizes carry their own hit area. Add the Checkbox for forms, and copy the hit-area utility for anything you built yourself. The Haptic Button, with the same size map plus press feedback and hold to confirm, is part of Wingo UI Pro. For the rest of the phone rules, from bottom sheets to safe areas, see Mobile UI in React.

Components in this post

  • Button

    The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps.

    Free
  • Checkbox

    A box that is on, off or mixed with a check that draws itself, a label row that is the whole target, cards, and groups with select all.

    Free
  • Haptic Button

    A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode.

    Pro
  • useMediaQuery

    SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint.

    Free

FAQ

What is the minimum touch target size in WCAG 2.2?

Level AA (SC 2.5.8) asks for 24 by 24 CSS pixels, with exceptions for small targets that are spaced far enough apart, links inside a sentence, unstyled browser controls, controls with an equivalent elsewhere on the page and essential presentations. Level AAA (SC 2.5.5) asks for 44 by 44 CSS pixels.

What is the minimum touch target size on iOS?

As of October 2026, Apple's Human Interface Guidelines list 44 by 44 points as the default control size on iOS and iPadOS and 28 by 28 points as the minimum. In a mobile browser with a width=device-width viewport, one CSS pixel is one point, so 44px in CSS matches Apple's 44 pt.

Is a 44px touch target enough for Android?

It meets WCAG AAA and Apple's default, and it is 4px short of Android's recommendation. Google's Android accessibility guidance asks for 48 by 48 dp with 8 dp between targets, and one dp is one CSS pixel in a mobile browser. If most of your users are on Android, use 48px on coarse pointers.

Does an invisible hit area count toward the target size?

Yes. WCAG measures the region that accepts pointer input, so padding or a pseudo-element hit area counts. It stops counting where an overflow-hidden ancestor clips it or where it overlaps a neighbor that does something else.

What is the pointer coarse media query?

@media (pointer: coarse) matches when the primary pointing device has limited accuracy, such as a finger on a touchscreen. Tailwind CSS v4.1 and later ship it as the pointer-coarse variant; any-pointer: coarse matches when any connected input is coarse, which includes a laptop with a touchscreen.

Do Wingo UI components meet 44px on touch screens?

The Button, Checkbox and Haptic Button do. Medium buttons grow from 40 to 44px under pointer: coarse, small ones keep their look and gain a 44px hit area, and checkbox rows are at least 44px tall with the whole row as the target. The Button and Checkbox are free; the Haptic Button is Pro.

  • Mobile UI
  • Accessibility
  • Tailwind CSS
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

Mobile-First React Components: 9 Rules With Code

Mobile-first React components, rule by rule: bottom sheets instead of popovers, 44px touch targets, safe areas, dvh, keyboard-aware bars and tables as cards.

SRSerban Rusu·Oct 9, 2026·20 min read
Mobile UI in React

Shadcn Responsive Dialog: Card on Desktop, Sheet on Phones

Build a shadcn responsive dialog once: a centered card above 768px, a draggable bottom sheet below it, one copy of the content and no hydration flash.

SRSerban Rusu·Oct 9, 2026·10 min read
Mobile UI in React

Web Vibration API: Haptic Feedback on Android and iPhone

The Web Vibration API in 2026: navigator.vibrate on Android, the iPhone switch tick that iOS 26.5 limits to real taps, and a React helper that fails quietly.

SRSerban Rusu·Oct 9, 2026·11 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI