Insights

Mobile-first, one thumb

Mobile-first, one thumb

Mobile-first gets said at every kickoff. The working version is narrower than the slogan: the phone layout is the one I write first, the target is a thumb rather than a cursor, and no interface is finished until something has measured it on a viewport that believes it is a phone. At 3:46 this morning I committed a component that satisfies all three. Thirty minutes later my own verification pass told me it was twelve pixels too small, and it took a while to work out which of us was wrong.

What does mobile first design mean once you are building it?

It means the phone is the default case in the code, not a breakpoint handled at the end. Four consequences follow, and between them they are the whole law as I run it. Touch is the primary input, so interactions go through Pointer Events rather than mouse handlers. Targets are finger-sized, with real space between them. Nothing important sits behind a hover. And a genuine mobile viewport signs off before I call any interface done.

The order is the part people skip. A desktop-first stylesheet that later adds @media (max-width:700px) rules has already made its layout decisions in a 1400px column, and the phone inherits whatever survives the overrides. Writing the narrow case first inverts that, and it flushes out a specific CSS trap early: a bare 1fr grid track floors at min-content, so one wide child, a long URL or a tabular dollar figure, silently pushes the whole grid past the viewport. I hit that in two separate engines on the same day in August 2026, and the fix was minmax(0,1fr) both times.

How big is a tap target, really?

There is no single universal number, and anyone who gives you one has picked a favourite. The documents most likely to be quoted at you disagree, on purpose, because they measure different things.

Apple's Human Interface Guidelines put it plainly in the Buttons section: a button needs a hit region of at least 44x44 pt. The Accessibility section of the same guidelines is more nuanced than the number usually quoted from it, listing 44x44 pt as the default control size for iOS and iPadOS and 28x28 pt as the minimum. It also gives spacing guidance that gets ignored more often than the size does:

Consider spacing between controls as important as size. Include enough padding between elements to reduce the chance that someone taps the wrong control. In general, it works well to add about 12 points of padding around elements that include a bezel.

Material Design 3 lands somewhere else, and explains its reasoning in physical units rather than pixels: "For most platforms, consider making touch targets at least 48 x 48dp. A touch target this size results in a physical size of about 9mm, regardless of screen size. The recommended target size for touchscreen elements is 7-10mm." It then adds a footnote acknowledging the disagreement: "Note: iOS recommends 44 x 44dp targets." For spacing it asks for 8dp between targets.

WCAG 2.2, a W3C Recommendation since 12 December 2024, sets a much lower floor and makes it a conformance requirement rather than a suggestion. Success Criterion 2.5.8, Target Size (Minimum), at Level AA, reads: "The size of the target for pointer inputs is at least 24 by 24 CSS pixels, except when..." with exceptions for spacing, an equivalent control, inline targets, user agent control, and essential presentation. The 44 number does appear in WCAG, but at Level AAA, in 2.5.5 Target Size (Enhanced): "The size of the target for pointer inputs is at least 44 by 44 CSS pixels."

SourceNumberStatus
Apple HIG, Buttons44x44 pt hit regionPlatform guidance
Apple HIG, Accessibility44x44 pt default, 28x28 pt minimumPlatform guidance
Material Design 348x48dp, about 9mmPlatform guidance
WCAG 2.2 SC 2.5.824x24 CSS pxLevel AA, testable
WCAG 2.2 SC 2.5.544x44 CSS pxLevel AAA, testable
Five squares drawn to scale against each other on paper stock, comparing minimum tap target sizes: 24 for WCAG level AA in CSS pixels, 28 for Apple's stated iOS minimum control size, a dashed 32 for this site's chips on a pointer device, 44 in terracotta for Apple's hit region and WCAG level AAA, and 48 for Material Design 3. A dashed circle at the same scale marks the roughly 9 millimetre physical size Material states for a 48dp target. Below, a table lists each source, its number, whether it is platform guidance or a testable WCAG level, and what the number is actually measuring.

I build to 44 on touch, because it is the strictest number two platform vendors and one WCAG level all reach, and because it costs almost nothing to honour. On pointer devices I let controls tighten, which is exactly where this morning went sideways.

Why did a passing component fail my own audit?

Because the test drove a narrow desktop window and I had asked the CSS a question about the pointer, not the width. Here is the actual rule, from the site's own stylesheet, committed at 3:46 on the morning of 4 September 2026 alongside the tag chips on the insights index:

.row-tags a,.post-tags a{display:inline-flex;align-items:center;min-height:44px;padding:0 11px;
  font-family:var(--mono);font-size:.7rem;letter-spacing:.06em;color:var(--stone);
  text-decoration:none;border:1px solid var(--line);border-radius:2px;
  transition:color .18s,border-color .18s}

/* Pointer devices do not need the 44px thumb rule, so the chips tighten up
   and the row reclaims the date column and arrow it had before. */
@media (hover:hover) and (pointer:fine){
  .row-tags a,.post-tags a,.tag-cloud a,.tag-cloud .is-here{min-height:32px}
}

The base rule is the mobile-first one: 44px minimum height for every chip. The media query is the enhancement, and it only applies where the primary input can hover and is accurate. On a phone it should never match.

The verification pass measured every chip on the index at a 390px viewport and reported a failure: 113 chips, all of them 32px tall, twelve pixels under the minimum. The measurement was correct. The conclusion drawn from it was not. The harness had launched a desktop WebKit and resized its window to 390 by 664, which is a narrow desktop browser and nothing else. It reports a fine pointer and hover support, because a mouse is still attached to it, so it matched the desktop branch and got the desktop chips. Re-running against a real touch profile changed the answer:

# what the failing run did: a desktop browser made narrow
ctx = browser.new_context(viewport={'width': 390, 'height': 664})
# -> hover:hover true, pointer:fine true, 113 chips at 32px

# what a phone actually is
ctx = browser.new_context(**p.devices['iPhone 13'])
# -> {'viewport': {'width': 390, 'height': 664}, 'device_scale_factor': 3,
#     'is_mobile': True, 'has_touch': True, 'default_browser_type': 'webkit'}
# -> hover:none, pointer:coarse, 113 chips at 44px

Same 390 pixels of width in both runs. Same page, same stylesheet, same browser engine. The only difference is that the second context tells the page a finger is coming, and the page believes it. The component had been right the whole time; the test had been asking the wrong question since the moment I wrote it.

Two side by side browser panels, both 390 pixels wide, showing the same page of tag chips. The left panel is a desktop WebKit window resized to 390 by 664: it reports hover:hover true and pointer:fine true, matches the desktop media query branch, and measures 113 chips at 32 pixels. The right panel is a Playwright iPhone 13 context at the same 390 by 664 with has_touch true: it reports hover:none and pointer:coarse, skips the desktop branch, and measures 113 chips at 44 pixels. A terracotta caption between them reads: a narrow viewport is not a phone.

What generalizes past the bug: a narrow viewport is not a phone, so test the pointer, not the pixel width. Any responsive test that resizes a window without emulating touch is testing a small window, and it will silently pass or fail every rule you wrote behind hover, pointer, any-hover, or any-pointer. This is the same failure shape as a verifier that checks for output rather than the right output, and the same shape as trusting a cache write that reported success without producing bytes. The measurement ran. It just measured something adjacent to the thing I cared about.

Where can a thumb actually reach on a tall phone?

Less of the screen than the layout usually assumes, and less of it every hardware generation. The most useful primary research on this is still Steven Hoober's field study for UXmatters, published 18 February 2013, in which he and other researchers made 1,333 observations of people using phones in public over two months. Of the people touching their screens, he recorded three grips: one handed 49%, cradled 36%, two handed 15%. Within one-handed use, 67% used the right thumb and 33% the left.

He also described the reach zones the way I still think about them: "Green indicates the area a user can reach easily; yellow, an area that requires a stretch; and red, an area that requires users to shift the way in which they're holding a device."

Two caveats before anyone builds on those numbers. They are thirteen years old, and the largest device in Hoober's data set was a Samsung Galaxy Note 2. Apple's specifications table currently lists the iPhone 13 at 390x844 pt and the iPhone 17 Pro Max at 440x956 pt, 112 points taller. The percentages are stale. The geometry they describe has only gotten more severe, because the pivot at the base of the thumb has not moved and the top corner has.

A tall phone drawn at 390 by 844 points with a thumb arc swept from the lower right corner, dividing the screen into three bands: an easy zone covering the lower two thirds toward the holding hand, a stretch band across the upper middle, and a shift-your-grip corner at the top left. Labels mark where the site's own furniture sits: the fixed header and theme chips in the stretch and shift zones, the tag chips and post rows in the easy zone. A second outline behind the phone shows the iPhone 17 Pro Max at 440 by 956 points, 112 points taller, with its top corner further outside the arc.

The part of Hoober's write-up I keep returning to is not a number at all. He notes that the people holding phones one-handed were "carrying bags, steadying themselves when in transit, climbing stairs, opening doors, holding babies, and so on." I trained as a counselor before I built full-time, and years of sitting with people in residential treatment and crisis work left me with an instinct that transfers directly here: the person on the other end of the interface is rarely giving it their full attention, and the cost of a mis-tap is paid by them, not by the layout. Designing for a distracted thumb is not a nicety. It is an accurate model of the user.

Why are hover-only menus broken on a phone?

Because there is no hover to trigger them, and the browser's emulation of one is deliberately inconvenient. MDN's definition of the hover: none media feature is precise about this: "The primary input mechanism cannot hover at all or cannot conveniently hover (e.g., many mobile devices emulate hovering when the user performs an inconvenient long tap), or there is no primary pointing input mechanism."

So a dropdown that opens on :hover is not degraded on a phone. It is a control with no way to operate it, unless the browser fires a synthetic hover on the first tap, which then eats that tap. Every hover reveal needs a tap equivalent, and every hover flourish needs gating so it never fires on touch. On this site the polaroid gallery straightens its tilted photos under a cursor, and the rule is fenced off:

@media (hover:hover){.gl-snap:hover{transform:rotate(0deg)}}

Without that fence, a tap can leave a card stuck in its hover state until you tap elsewhere, which reads as a bug even to someone who could not tell you what a hover is.

Why Pointer Events instead of mouse events?

Because one code path then serves mouse, finger and stylus, and there is no longer any compatibility reason to avoid it. Pointer Events Level 3 became a W3C Recommendation on 30 June 2026, and MDN currently marks the API as Baseline: widely available. MDN's framing of the problem is the reason the spec exists:

Much of today's web content assumes the user's pointing device will be a mouse. However, since many devices support other types of pointing input devices, such as pen/stylus and touch surfaces, extensions to the existing pointing device event models are needed.

In practice pointerdown, pointermove, pointerup and pointercancel replace their mouse equivalents one for one, setPointerCapture keeps a drag attached to the element that started it even when the finger leaves, and touch-action stops the browser trying to scroll mid-drag. The image cropper in the CMS admin is the whole pattern in one handler:

box.addEventListener('pointerdown', function (e) {
  e.preventDefault();
  var r = box.getBoundingClientRect();
  var handle = e.target.classList.contains('h') ? e.target.className.replace('h ', '') : null;
  drag = { x0: e.clientX, y0: e.clientY, l: box.offsetLeft, t: box.offsetTop,
           w: r.width, h: r.height, mode: handle || 'move' };
  box.setPointerCapture(e.pointerId);
});

Nothing in that handler knows or cares whether a mouse or a thumb started the drag. Written with mousedown, it would have produced an admin panel where a client can crop a photo at a desk but not on the phone in their pocket, which is the version of "works" that stops being true the moment somebody travels.

What does testing on a real mobile viewport actually involve?

A headless WebKit at 390 by 664 with touch emulation on, crawling the site and asserting things a screenshot cannot tell you. Playwright's Python bindings drive WebKit here because WebKit is what iPhones run. The crawler flags any page whose scrollWidth exceeds 392, plus any element whose bounding rect extends past the same line. Three lessons from a full front-and-admin audit across three sites in August 2026 saved the most time on re-runs:

  • Text nodes dodge rectangle scans. A long unbroken URL overflows as raw text with no element extending past the viewport, so the document is wider than the screen and nothing you can query is guilty. Detect it by document width, then find it by toggling display:none per subtree and re-reading scrollWidth.
  • A link crawler will follow the admin logout link and kill its own session, after which every remaining admin page audits as the login screen and passes beautifully. Skip logout hrefs and assert a per-page marker that proves you are still signed in.
  • Pattern-capped crawls miss exactly the deep pages real users screenshot. Force-discover one URL per slug from the hub pages instead of sampling.

The audit is also where the iOS rule that costs the most debugging time gets caught: overflow-x:hidden on <html> or <body> breaks position:fixed on iOS Safari, because children of the body detach during momentum scroll and strand mid-page. overflow-x:clip clips without creating a scroll container, and it is what sits on line 18 of this site's stylesheet. Motion carries the same tax, which is why the scroll-scrubbed video technique gets tested on a phone first.

All of it lives in a skill file the agents load before they touch an interface, alongside the rest of the files that keep slop out of my projects. Writing the law down once is the only way it survives a fast build.

The case I have not solved yet

A Windows laptop with a touchscreen and a mouse reports pointer: fine and hover: hover, because those features describe the primary input mechanism. So it gets the 32px chips, and if its owner reaches up and taps the screen, they are tapping a target built for a cursor. MDN's any-pointer feature exists exactly for this: it matches if any available input includes a coarse pointer, not just the primary one. Tightening the gate to (hover:hover) and (pointer:fine) and (not (any-pointer:coarse)) would fix the hybrid case and would also hand the 44px chips to anyone who has ever plugged a graphics tablet into a desktop. I have not decided which of those two groups I would rather be wrong about, so the rule ships as it is, and the audit harness now runs both contexts on every pass so that at least I find out.

Common questions

What is the minimum tap target size for mobile?

There is no single number. Apple's Human Interface Guidelines call for a hit region of at least 44x44 pt on a button, Material Design 3 asks for 48x48dp (about 9mm physically), and WCAG 2.2 sets a testable floor of 24x24 CSS pixels at Level AA and 44x44 CSS pixels at Level AAA. Building to 44 on touch meets the highest number Apple and WCAG Level AAA both state; Material asks for more, at 48dp.

Why does a 390px browser window not count as testing on mobile?

A resized desktop browser still reports a fine pointer and hover support, so it matches every desktop branch of your CSS. On 4 September 2026 that made 113 tag chips measure 32px in an audit at 390px wide while a real touch context at the same width measured 44px. Emulate touch (has_touch, pointer:coarse) or you are testing a small window.

Are hover menus broken on phones?

Yes, unless there is a tap equivalent. MDN describes touch devices as unable to hover conveniently, emulating it only through an inconvenient long tap. A dropdown that opens on :hover has no reliable way to be operated by a finger, and a hover flourish that is not gated behind a hover media query can leave a card stuck in its hover state after a tap.

Should I use Pointer Events or mouse events?

Pointer Events. Pointer Events Level 3 became a W3C Recommendation on 30 June 2026 and MDN marks the API as Baseline: widely available. One handler using pointerdown, pointermove, pointerup and setPointerCapture serves mouse, finger and stylus from a single code path, where mousedown serves only the desk.

Why does overflow-x:hidden break fixed positioning on iOS?

On iOS Safari, overflow-x:hidden on the html or body element makes children of the body detach during momentum scroll and strand mid-page, so position:fixed elements drift. overflow-x:clip clips the overflow without creating a scroll container, which keeps fixed positioning alive. Check both html and body when a floating button misbehaves.

Related

← All insights