44px touch targets without redesigning your UI
How karaUI gives small controls a 44×44px hit area on touch screens with one invisible ::after box, without changing how anything looks or lays out, plus the overlap math, the hybrid-device caveat and iOS's 16px input rule.
- Accessibility
- Touch targets
- WCAG
- CSS
- Tailwind CSS
- Mobile
Most of karaUI's controls are drawn smaller than a fingertip. The small Button is 32px tall, the icon button is 32×32, a Pagination page is a 36px circle and its dots are 20px. On a trackpad that is plenty. On a phone it is a miss waiting to happen. The fix I wanted could not touch a single visual size, because each of those sizes was chosen for density and alignment on desktop. This post covers how the fix works, where it breaks, and how to apply it to your own controls.
Why 44px
The same few numbers come up every time. WCAG 2.5.5 Target Size (Enhanced), a Level AAA criterion, asks for pointer targets of at least 44 by 44 CSS pixels. WCAG 2.2 added 2.5.8 Target Size (Minimum) at Level AA: 24 by 24, or enough spacing that a 24px circle centered on each undersized target doesn't intersect another target or another target's circle. Apple's Human Interface Guidelines recommend a hit target of at least 44×44 points for anything tappable. With the usual width=device-width viewport, a CSS pixel on an iPhone is one point, so on iOS the two 44s are the same size.
The reason is anatomy, not taste. A fingertip's contact patch is far wider than a mouse pointer's hotspot, and the finger hides the thing it is aiming at. karaUI aims for 44 on touch: it is the stricter number, and the one iOS users already have in their thumbs.
Why not make buttons bigger?
The obvious fix is to draw everything at 44px. I didn't, for three reasons.
- Density on desktop is a feature. A toolbar of 44px icon buttons, a 44px pagination row, 44px tag chips: with a mouse, that is space taken from content.
- Swapping sizes per device moves layout. A
@media (pointer: coarse)rule that setsheight: 44pxworks, but now a toolbar that fit on one line wraps on a tablet and a table row loses its rhythm. Every layout has to be checked twice. - Visual size and hit size are different properties. One is what you see, the other is where a press counts. Tie them together and every touch fix becomes a redesign.
So the goal was narrower: keep every visual size, leave layout alone, grow only the area that catches the press, and only on touch.
One invisible box
This class from lib/touch.ts does all of it:
// lib/touch.ts
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";Written as plain CSS, that is:
/* What TOUCH_TARGET_CLASS amounts to, without the Tailwind syntax */
@media (pointer: coarse) {
.control::after {
content: "";
position: absolute; /* against the control, which is relative */
top: 50%;
left: 50%;
width: max(100%, 2.75rem); /* never smaller than the control */
height: max(100%, 2.75rem); /* 2.75rem = 44px at a 16px root */
translate: -50% -50%;
}
}The ::after is positioned against the control, centered, and max(100%, 2.75rem) on each axis. Because it is max(), it can only grow:
- a 32px icon button gets a 44px box that reaches 6px past each edge;
- the 40px-tall default Button is already wider than 44px, so it only grows vertically, 2px above and below;
- the 48px large Button is left exactly as it is.
Hit-testing a pseudo-element lands on its owner, so the event target is the button itself: onClick, focus and :active all behave as if the button were bigger. Nothing paints, because the box has no background or border. Nothing moves, because an absolutely positioned box takes no space in layout. And every utility sits behind pointer-coarse:, Tailwind's @media (pointer: coarse) variant, so mouse and trackpad rendering is untouched.
Every Button size carries the class, and so do Switch, Checkbox, Radio Group, Toggle, the Select trigger, the pill and segment Tabs triggers, Pagination, Number Stepper and Copy Button. The full list is on the Mobile & responsive page. For your own controls, import the class and add it:
import type { ComponentProps } from "react";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Looks like a 32px button everywhere; catches taps like a 44px one on touch.
export function IconButton({ className, ...props }: ComponentProps<"button">) {
return (
<button
type="button"
className={cn(
"relative inline-grid size-8 place-items-center rounded-md",
TOUCH_TARGET_CLASS,
className,
)}
{...props}
/>
);
}Four rules for the element
The box only works if the element it goes on plays along. These come straight from the comment above TOUCH_TARGET_CLASS:
- It must be the containing block. Add
relative, and keep it mergeable so a consumer'sabsolutestill wins throughcn(). - It must not clip itself.
overflow-hiddenclips the overhang, and the hit area with it. - It must render children.
<input>and other void elements have no::after. Put the class on the wrapper or the label instead. - It must not already use
::afterfor something visible.
Rule two is the one that bites, because plenty of controls clip on purpose. Button's ripple is the worked example: with ripple on, the button is overflow-hidden pointer-coarse:overflow-visible, and the ripple layer clips itself on an inner span. Pagination goes one step further for its rolling page numbers: the button never clips, only a span inside it does. The pattern:
// Clipping on the button would clip its hit area too.
// Clip an inner layer instead, and let the button overhang on touch.
<button
className={cn(
"relative overflow-hidden pointer-coarse:overflow-visible",
TOUCH_TARGET_CLASS,
)}
>
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
{/* ripple, rolling digits, anything that must stay inside the shape */}
</span>
{children}
</button>Dense rows and the overlap math
Centered boxes overlap when controls sit close together. For two controls smaller than 44px along the row, the overlap is simply 44 − pitch, where pitch is the control's size plus the gap:
- two 32px icon buttons 4px apart (36px pitch): 8px of overlap;
- Pagination pages, 36px with a 4px gap (40px pitch): 4px;
- Pagination dots, 20px with a 2px gap (22px pitch): 22px, half the box.
The later element in the DOM wins the overlap: both boxes are positioned with no z-index, so the later one paints, and hit-tests, on top. That is fine while the strip it takes stays away from the earlier control's center. The later neighbour's box starts pitch − 22px from that center: 14px at a 36px pitch, 10px at 32px, and zero at 22px, where a tap on the dead center of one dot would go to the next. karaUI draws the line at about 32px. Below that, use TOUCH_TARGET_Y_CLASS, which grows the box vertically only. Pagination's dots use it, so each one stays 20px wide but is 44px tall on touch.
import { TOUCH_TARGET_Y_CLASS } from "@/lib/touch";
// 20px swatches, 2px apart: a 22px pitch. Growing sideways would hand half of
// each swatch to its neighbour, so grow vertically only.
<div className="flex gap-0.5">
{colors.map((color) => (
<button
key={color.id}
type="button"
aria-label={color.name}
className={cn("relative size-5 rounded-full", TOUCH_TARGET_Y_CLASS)}
style={{ background: color.value }}
/>
))}
</div>Caveats
Hybrid devices
pointer: coarse describes the device's primary pointer, not the one in the user's hand. A laptop with a touchscreen keeps the trackpad as primary, so a finger on that screen gets the drawn size, not 44px. A tablet with a mouse attached keeps touch as primary, so the mouse gets the enlarged boxes. They are invisible and mostly harmless, except in the overlap strips, where a click right at a control's edge lands on its neighbour.
No media query can say which pointer a given press came from; the query is about the device, the question is about the gesture. Tailwind also ships any-pointer-coarse:, which matches if any attached pointer is coarse. That would cover the touchscreen laptop, at the price of overlapping boxes under its mouse all the time. karaUI stays on the primary pointer, which is right on every machine that is one thing or the other.
Scroll containers
The overhang is real geometry. Inside a scroll container it counts as scrollable overflow, so a control flush against the scroller's edge can add a few pixels of scroll on touch screens. Pad that side by the overhang, which is (44 − size) / 2:
// A 32px control overhangs by (44 - 32) / 2 = 6px on each side.
// Pad the scroller by that much so the overhang isn't scrollable overflow.
<div className="flex gap-2 overflow-x-auto px-1.5">
{chips}
</div>The other 16px: input zoom
Hit areas are half of “works on a phone”. The other half is iOS Safari zooming the page when a text field with a font size below 16px gets focus, then leaving the page zoomed in after the field is done with. karaUI's Input and Textarea set text-base (16px) on the field itself, so focusing them never triggers it.
// 16px on the field itself. The label and helper text can stay smaller.
<input className="text-base leading-6" />Checklist
- Keep your visual sizes. Add
TOUCH_TARGET_CLASSto anything tappable that is smaller than 44px on either axis. - The element is
relative, doesn't clip itself, isn't a void element, and has a free::after. - Pitch under about 32px? Use
TOUCH_TARGET_Y_CLASS. - Pad scrollers by the overhang on the side a control touches.
- Give text fields 16px text.
- Check it on a real phone, and use the switch in the demo above to see where boxes overlap.
Related components
- ButtonThree buttons in one install: a Button that dips when pressed (with an opt-in Material ripple), a StatefulButton that rolls its label through loading, success and error, and a MagneticButton that drifts toward the pointer. Every size gets an invisible 44px hit area on touch screens.
- PaginationPage navigation with sibling and boundary ellipsis logic, a spring-gliding active pill, numbers that roll as the window shifts, arrow-nudge prev/next controls, and a compact dots variant.
- InputA labelled form field with left and right icon slots: setting error shakes it, turns the border red and blurs a message in underneath, while success draws a check mark at the right edge. Works controlled or uncontrolled, with a classNames hook for every part.
- SwitchAn on/off control exposed as role="switch": a heavy spring carries the thumb across, it squashes a little while held, it shakes if pressed while disabled, and on coarse pointers the hit area grows to 44px. Label text is optional.