Tailwind v4 pointer-* Variants: Sizing Touch Targets by Input Device, Not Screen Width

The Tailwind pointer-coarse variant sizes touch targets by input device instead of viewport width. Compiled CSS for every pointer-* variant, plus WCAG sizes.

Steven Richardson
Steven Richardson
· 9 min read

I inherited a component library last year where every interactive control carried some variation of p-3 md:p-1.5. The reasoning was obvious enough — small screens are touchscreens, big screens have mice — and it fell apart the first time someone opened the admin on a Surface. Chunky finger-sized buttons on a phone, fiddly 12px hit areas on a device the user was actually poking with a finger.

Viewport width has never told you what the user is holding. The tailwind pointer-coarse variant does, and it has been core in v4 for a while now without much fanfare. Every class in this article was compiled against tailwindcss@4.3.3 before I wrote about it, so the selectors below are what you actually get.

What pointer-coarse and pointer-fine compile to#

Six variants, all thin wrappers over CSS Media Queries Level 5 features. No plugin, no config.

Class Generated CSS
pointer-fine: @media (pointer: fine)
pointer-coarse: @media (pointer: coarse)
pointer-none: @media (pointer: none)
any-pointer-fine: @media (any-pointer: fine)
any-pointer-coarse: @media (any-pointer: coarse)
any-pointer-none: @media (any-pointer: none)

So the before/after on that inherited library is a one-line rewrite:

<!-- Guessing at input device from viewport width -->
<button class="p-3 md:p-1.5">Save</button>

<!-- Reading the actual input device -->
<button class="p-1.5 pointer-coarse:p-3">Save</button>

Note the direction flipped. The breakpoint version starts generous and shrinks on wide screens; the pointer version starts at the mouse size and grows for fingers. That matters, and I come back to it below.

coarse means "at least one input mechanism includes a pointing device of limited accuracy" — a finger, mostly. fine means an accurate device: mouse, trackpad, stylus. none means no pointing device at all, which in practice is TV browsers and keyboard-only kiosks.

pointer-coarse versus any-pointer-coarse#

This is the distinction that decides whether your code works on hybrid devices.

pointer-* tests the primary pointing device. any-pointer-* matches if any connected device qualifies. On an iPad with a Magic Keyboard attached, pointer-coarse and any-pointer-fine are both true at the same time — the finger is primary, the trackpad exists.

That gives a clean decision rule:

  • Sizing goes on pointer-*. You want the hit area to suit whatever the user is most likely reaching with right now.
  • Hover affordances go on any-pointer-fine. If a precise device is attached at all, hover-to-reveal is a reasonable thing to offer. If none is, hover is emulated by tap and the menu will stick open.
<!-- Size for the primary device -->
<div class="gap-1.5 pointer-coarse:gap-3">

<!-- But only offer hover-reveal if some precise device exists -->
<div class="opacity-0 any-pointer-fine:group-hover:opacity-100">

Building up from the accessibility floor#

Here is where the direction of the rewrite matters. WCAG 2.2 Success Criterion 2.5.8 Target Size (Minimum) sets a Level AA floor of 24×24 CSS pixels, with exceptions for spacing, inline targets and a few other cases. Success Criterion 2.5.5 Target Size (Enhanced) raises it to 44×44 at Level AAA.

Tailwind's default --spacing is 0.25rem, so the numbers line up almost suspiciously well:

  • size-61.5rem24px — exactly the 2.5.8 minimum
  • size-112.75rem44px — exactly the 2.5.5 enhanced target

Which gives you the pattern:

<button class="size-6 pointer-coarse:size-11" aria-label="Delete row">
  <x-icon name="trash" class="size-4" />
</button>

The base state satisfies AA for everyone. Coarse pointers get the AAA size. Nothing anywhere in the cascade drops below 24px, which is the failure mode of the md:p-1.5 approach — it deliberately shrinks past the floor on wide viewports, and takes touchscreen laptops down with it.

When you genuinely can't grow the visual box — a dense toolbar, an icon inside a table cell — expand the hit area instead of the element:

<button class="relative size-6 before:absolute before:-inset-2 before:content-[''] pointer-coarse:before:-inset-3">
  <x-icon name="pencil" class="size-4" />
</button>

before:-inset-2 gives a 24px visual box a 40px clickable region; pointer-coarse:before:-inset-3 takes it to 48px. Verified compiled output:

@media (pointer: coarse) {
  .pointer-coarse\:before\:-inset-3::before {
    content: var(--tw-content);
    inset: calc(var(--spacing) * -3);
  }
}

Remember before:content-[''] — a pseudo-element with no content generates no box, and the hit area silently does nothing.

Patterns: icon buttons, row actions and hover-gated UI#

Table row actions. The hover-reveal pattern is fine on a mouse and hostile on a finger. Gate the reveal on a fine pointer and make the actions permanently visible otherwise:

<tr class="group">
    <td>{{ $customer->name }}</td>
    <td class="text-right">
        <div class="flex justify-end gap-1.5
                    pointer-coarse:gap-3
                    hidden pointer-coarse:flex
                    pointer-fine:group-hover:flex">
            <x-row-action :href="route('customers.edit', $customer)" icon="pencil" />
            <x-row-action :href="route('customers.destroy', $customer)" icon="trash" variant="danger" />
        </div>
    </td>
</tr>

The nesting compiles the way you'd hope — Tailwind wraps the group-hover selector in both media queries:

@media (pointer: fine) {
  @media (hover: hover) {
    .pointer-fine\:group-hover\:flex:is(:where(.group):hover *) {
      display: flex;
    }
  }
}

That gap-1.5 pointer-coarse:gap-3 is not decoration. Edit and Delete sitting 6px apart is a mis-tap waiting to happen; on a coarse pointer you want real separation between a safe action and a destructive one. If you're reaching for parent-state selectors more generally, the has-* variant covers the cases group-* can't.

Tap-to-open instead of hover-to-open. For a tooltip or dropdown trigger, any-pointer-fine: decides whether the hover path is worth wiring up at all:

<button
  popovertarget="specs"
  aria-describedby="specs"
  class="min-h-11 min-w-11 pointer-fine:min-h-6 pointer-fine:min-w-6"
>
  Specs
</button>

<div id="specs" popover class="opacity-0 any-pointer-fine:group-hover:opacity-100 [&:popover-open]:opacity-100">
  ...
</div>

The popover still opens on click everywhere; hover is an enhancement layered on top only where a precise device exists. If you're positioning that panel, CSS anchor positioning in Tailwind v4 is a better answer than a wrapper div and absolute. And when the open/closed state is driven by an attribute rather than :hover, the data-* and aria-* variants keep it out of JavaScript entirely.

The pointer variants sit in a family of media-feature variants that all read device or user capability rather than screen size:

  • motion-reduce: / motion-safe:@media (prefers-reduced-motion: reduce | no-preference)
  • forced-colors:@media (forced-colors: active)
  • contrast-more: / contrast-less:@media (prefers-contrast: more | less)
  • portrait: / landscape:@media (orientation: portrait | landscape)
  • noscript:@media (scripting: none)

Worth pairing with pointer sizing: container queries solve the other half of the problem the breakpoint approach was badly approximating. Breakpoints tell you about the viewport, container queries tell you about the space a component actually occupies, and pointer variants tell you about the input device. Those are three different questions and md: was answering all of them.

For layout that has to survive a mobile browser chrome that appears and disappears, the dynamic viewport height units are the companion piece.

Gotchas and how to test pointer-coarse without a phone#

hover: already guards itself. Tailwind v4 compiles hover: to @media (hover: hover) { &:hover }, not a bare :hover. Confirmed from the compiled output:

@media (hover: hover) {
  .hover\:bg-red-500:hover {
    background-color: var(--color-red-500);
  }
}

So hover:bg-gray-100 on a touch-only device already does nothing, and wrapping it in pointer-fine: is usually redundant. Where any-pointer-fine: still earns its place is layout decisions — deciding whether an element starts hidden at all — because hover: hover and pointer: fine are different queries and can disagree. A phone with a Bluetooth mouse paired reports hover: hover while pointer: coarse is still true.

There is no max-pointer-coarse. These are discrete keyword features, not ranges, so the max-* prefix doesn't apply. max-pointer-coarse:p-1 compiles to absolutely nothing — no error, no warning, no CSS. Use pointer-fine: for the inverse.

They're media features, not JavaScript. No detection script, no class toggling on the html element, no hydration mismatch to worry about. They re-evaluate live: plug in a mouse mid-session and the primary pointer flips, and the browser restyles. That's a genuine advantage over the 'ontouchstart' in window sniffing that a lot of older code still carries.

Testing. Chrome DevTools device emulation reports a coarse pointer, which is the fastest way to check this without hardware. Open DevTools, toggle the device toolbar (Cmd+Shift+M), pick any mobile device — (pointer: coarse) now matches and your pointer-coarse: rules apply. Firefox's Responsive Design Mode has a "touch simulation" toggle that does the same. Both are honest enough for layout work; for real thumb ergonomics you still want a physical device.

Browser support is a non-issue. Both pointer and any-pointer are Baseline Widely Available, supported across browsers since December 2018. Anything that runs Tailwind v4's output supports them.

Wrapping Up#

Do the audit: grep your Blade templates for md:p-, md:size-, lg:gap- and md:group-hover on anything interactive. Most of those are guessing at an input device, and pointer-coarse: answers the question directly. Start from a base that clears 24px and grow upward — never shrink below the floor for the mouse case.

While you're in there, container queries will likely replace a second chunk of those breakpoint variants, and the nth-* family cleans up the row-striping selectors you'll walk past on the way.

FAQ#

What does pointer-coarse mean in Tailwind CSS?

pointer-coarse is a variant that compiles to @media (pointer: coarse). It applies its utilities when the user's primary pointing device has limited accuracy — a finger on a touchscreen, typically. So pointer-coarse:size-11 grows a control to 44px on touch devices and leaves it alone everywhere else. It is core in Tailwind v4 and needs no plugin or configuration.

What is the difference between pointer and any-pointer in CSS?

pointer tests the primary pointing device; any-pointer matches if any connected device meets the criteria. On an iPad with a keyboard case attached, pointer: coarse and any-pointer: fine are both true simultaneously. The practical rule is to size targets with pointer-*, because that reflects what the user is most likely reaching with, and gate hover-based affordances with any-pointer-fine, because that tells you whether hover is meaningful at all.

How do I disable hover styles on touch devices in Tailwind?

You mostly don't have to. Tailwind v4 compiles the hover: variant to @media (hover: hover) { &:hover }, so hover styles are already suppressed on devices that only emulate hover through tap. Where you still need explicit control is layout — an element that starts hidden and is revealed on hover will be permanently unreachable on touch, so gate the reveal with any-pointer-fine: and provide an always-visible or tap-triggered alternative.

How big should a touch target be for accessibility?

WCAG 2.2 Success Criterion 2.5.8 sets a Level AA minimum of 24×24 CSS pixels, with exceptions for adequately spaced targets and inline links. Success Criterion 2.5.5 raises it to 44×44 at Level AAA. In Tailwind's default scale those land on size-6 and size-11 exactly, which makes size-6 pointer-coarse:size-11 a convenient pattern that clears AA everywhere and hits the enhanced target on touch.

Should I use breakpoints or pointer variants to detect touch?

Pointer variants, every time. A breakpoint tells you how wide the viewport is, which correlates with input device only loosely and gets both edge cases wrong: a touchscreen laptop matches lg: while needing coarse-pointer targets, and a tablet requesting the desktop site matches wide breakpoints too. Breakpoints are still the right tool for layout — column counts, sidebar visibility — just not for hit-area sizing.

Do pointer-* variants work in all browsers?

Yes, for any browser you would realistically ship Tailwind v4 to. The underlying pointer and any-pointer media features are Baseline Widely Available and have been supported across browsers since December 2018. There is no fallback to write and no feature query needed; the only real caveat is that emulated environments like DevTools device mode report a coarse pointer, which is useful for testing but is not the same as testing on hardware.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.