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

> Minimum touch target size per WCAG 2.2 (24 and 44px), iOS (44pt) and Android (48dp), and how to get 44px taps in React and Tailwind without desktop bloat.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind

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

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 size | Spacing | Status |
| --- | --- | --- | --- |
| [WCAG 2.2, SC 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) | 24 by 24 CSS px | smaller targets pass when spaced out | Level AA |
| [WCAG 2.2, SC 2.5.5 Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) | 44 by 44 CSS px | none | Level AAA |
| [Apple HIG, iOS and iPadOS](https://developer.apple.com/design/human-interface-guidelines/accessibility) | 44 by 44 pt default, 28 by 28 pt minimum | about 12 pt around bezeled controls, 24 pt around borderless ones | Guideline |
| [Apple HIG, macOS](https://developer.apple.com/design/human-interface-guidelines/accessibility) | 28 by 28 pt default, 20 by 20 pt minimum | same | Guideline |
| [Android accessibility help](https://support.google.com/accessibility/android/answer/7101858) | 48 by 48 dp, about 9 mm | 8 dp or more | Recommendation |

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](https://www.w3.org/TR/WCAG22/#target-size-minimum) 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](https://kotlinlang.org/api/compose-multiplatform/material3/androidx.compose.material3/minimum-interactive-component-size.html) 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](https://tailwindcss.com/docs/hover-focus-and-other-states). 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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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.

> Live demo (Checkbox): 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. Try it at [Checkbox](https://wingo-ui.com/components/checkbox) and install it with `npx wingo-ui@latest add checkbox`.

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`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/any-pointer) 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](https://wingo-ui.com/components/use-media-query) exports it as `TOUCH_QUERY`, so components and app code agree on what touch means.

> Live demo (useMediaQuery): Your device answers live: useIsTouch() is pointer: coarse, the hover card needs a mouse or trackpad, and the last card runs any-pointer: coarse. Try it at [useMediaQuery](https://wingo-ui.com/components/use-media-query) and install it with `npx wingo-ui@latest add use-media-query`.

## 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](https://wingo-ui.com/blog/mobile-first-react-components) covers the same split for layout, and the [SSR-safe React use mobile hook tutorial](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) 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](https://wingo-ui.com/pricing). For the rest of the phone rules, from bottom sheets to safe areas, see [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui).

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [Checkbox](https://wingo-ui.com/components/checkbox) (Free): 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. Install: `npx wingo-ui@latest add checkbox`
- [Haptic Button](https://wingo-ui.com/components/haptic-button) (Pro): 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. Install: `npx wingo-ui@latest add haptic-button`
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) (Free): SSR-safe hooks for media queries, phone widths, touch screens and the current Tailwind breakpoint. Install: `npx wingo-ui@latest add use-media-query`

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

---

Source: https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind
