Hover States Are a Lie on Touch: Designing Interactions That Work on Every Input
Hover is a desktop assumption baked into most component libraries. Here's how to design and build interactions that behave correctly across mouse, touch, stylus, and hybrid devices in 2026.

A designer hands you a card component with a lovely hover animation: it lifts, a shadow blooms, a "Learn more" link fades in. It looks great in Figma. It ships. Then a user on an iPad taps the card, sees nothing happen, taps again, and finally the link appears — and immediately triggers. That is not a bug in one component. That is a design system quietly assuming everyone has a mouse.
Why hover is a broken assumption in 2026
A decade ago, "desktop" meant mouse and "mobile" meant touch. That map stopped matching the territory a long time ago. In 2026, a single user might browse the same site from:
- A laptop with a trackpad (fine-grained hover)
- A tablet with a stylus (hover on some devices, none on others)
- A phone (no hover at all)
- A convertible laptop in tent mode (touch primary, mouse absent)
- A TV with a remote (D-pad focus, no pointer)
When you attach meaning — not just decoration — to :hover, that meaning disappears for anyone whose primary input can't hover. The classic offenders: tooltips that explain an icon-only button, dropdown menus that open on hover, "delete" buttons that only appear when you hover a table row, and cards where the CTA is hidden until hover.
On touch, most browsers emulate hover on the first tap and click on the second. That double-tap-to-activate pattern is a known accessibility and conversion killer. Users don't know why the button "didn't work" the first time.
The rule we use
Hover can add polish. Hover cannot be the only way to reach information or trigger an action.
If a hover interaction reveals content or affordance that isn't available another way, it's broken on at least one class of device you support.
Detect input capability, not device type
User-agent sniffing for "mobile" is a dead end — a Surface Pro is both. The correct primitive is the CSS pointer media queries, which have been stable across every major browser for years.
/* Primary input is fine-grained (mouse, trackpad, stylus) */
@media (pointer: fine) { … }
/* Primary input is coarse (finger) */
@media (pointer: coarse) { … }
/* Any available input can hover (hybrid laptops with touchscreens qualify) */
@media (any-hover: hover) { … }
/* No available input can hover reliably */
@media (any-hover: none) { … }
The subtle one is any-hover vs hover. hover reports the primary input's capability. any-hover reports whether any connected input can hover. On a touchscreen laptop, hover: none may be true while any-hover: hover is true. Which do you want?
For progressive enhancement — showing hover affordances only when they'll be usable — any-hover: hover is usually the safer gate. For deciding hit-target size, pointer: coarse is the right signal.
.card__cta {
/* Visible by default — works everywhere */
opacity: 1;
}
@media (any-hover: hover) {
.card__cta {
/* Only hide-on-hover-reveal for inputs that can hover */
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .card__cta,
.card:focus-within .card__cta {
opacity: 1;
}
}
Notice two things: the base state is the accessible state, and :focus-within rides shotgun with :hover so keyboard users get the same reveal.
The patterns that break most often
Tooltips on icon-only buttons
If a button's only label is a tooltip that appears on hover, touch users have no way to learn what it does. Fixes, in order of preference:
- Add a visible label. Icon + text is almost always better for conversion anyway.
- If space is tight, use a persistent accessible name via
aria-label, and make the tooltip triggerable by both hover and long-press or tap. Libraries like Radix and Ariakit handle this correctly out of the box. - Never rely on
titleattributes for critical information. They don't appear on touch, they're inconsistent with screen readers, and their timing is browser-dependent.
Hover-triggered menus
Mega menus that open on mouseover are a desktop convention that stops working the moment a finger is involved. The fix isn't to disable them — it's to make click/tap the source of truth and hover an optional accelerator.
// Click opens/closes on every device.
// Hover only *opens* (never closes) on fine-pointer devices.
function MegaMenu() {
const [open, setOpen] = useState(false);
const canHover = useMediaQuery('(any-hover: hover)');
return (
<div
onMouseEnter={canHover ? () => setOpen(true) : undefined}
onMouseLeave={canHover ? () => setOpen(false) : undefined}
>
<button
aria-expanded={open}
aria-controls="menu-panel"
onClick={() => setOpen(o => !o)}
>
Products
</button>
{open && <MenuPanel id="menu-panel" />}
</div>
);
}
The aria-expanded attribute is not optional — it's how assistive tech knows the button is a disclosure.
Row actions that appear on hover
Gmail-style row actions (archive, delete, mark unread) that only appear on hover are a mobile disaster. The pragmatic patterns:
- On coarse pointers, show a single overflow menu (
⋯) per row that opens a sheet with all actions. - On fine pointers, show inline icons on hover.
- Keep the overflow menu available on fine pointers too — it's a fallback for keyboard users and anyone who missed the hover state.
Hit targets and the 24px minimum
WCAG 2.2 introduced a target size requirement (AA at 24×24 CSS pixels, AAA at 44×44) that most component libraries still ship in violation of. When you know the primary pointer is coarse, you can bump this up without penalizing desktop density:
.icon-button {
min-height: 32px;
min-width: 32px;
}
@media (pointer: coarse) {
.icon-button {
min-height: 44px;
min-width: 44px;
}
}
Don't rely on padding alone — a 16px icon with 4px padding is a 24px visual target but often a much smaller hit target depending on how the element is composed. Set min-height/min-width on the interactive element itself.
The 300ms tap delay is (mostly) gone, but…
Modern browsers removed the 300ms click delay for pages with a proper viewport meta tag. If you're seeing sluggish taps, check for:
- A missing or misconfigured
<meta name="viewport"> - Custom JavaScript that debounces click handlers
- Overlapping elements that swallow the first tap
Testing without buying six devices
You don't need a device lab to catch most of this. Our checklist:
- Chrome/Edge DevTools device emulation with touch simulation on. It won't perfectly replicate hover suppression, but it catches obvious breaks.
- Force the media query in DevTools: Rendering panel → "Emulate CSS media feature" → set
pointerandany-hover. This is the single most useful button in the browser for this work. - Keyboard-only pass. Tab through every hover-dependent affordance. If it's not reachable, touch users won't reach it either.
- Real device smoke test on an iPad and one Android phone before shipping. Emulators lie about hover more than about anything else.
Design system implications
If you maintain a design system, hover-safety belongs in the component contract, not in each consumer's head. A few concrete moves:
- Bake
any-hovergates into components that use hover reveals. Consumers should not have to remember. - Add a "touch parity" row to every component's documentation, stating explicitly how the component behaves without hover.
- Ban
title-attribute-as-tooltip in lint rules. It's a footgun. - Store hit-target sizes as design tokens with coarse/fine variants, not as one-off values.
We covered token durability in a previous piece on design tokens that survive engineering; the same principle applies here — encode the rule once, in the primitive, so downstream teams can't accidentally violate it.
Where we'd start
If you inherit a codebase and want to fix this without a rewrite, do it in this order:
- Grep for
:hoverin your component library. For each match, ask: does this hover reveal information or an action? If yes, that's the shortlist. - Add
:focus-withinalongside every:hoverthat reveals something. This is a five-minute win that helps keyboard users immediately. - Wrap hover-only reveals in
@media (any-hover: hover)and make the base state the accessible one. - Audit icon-only buttons for accessible names, then upgrade tooltips to a library that handles touch.
- Add the
pointer: coarsehit-target bump globally.
None of this is expensive. It's a week of focused work on most codebases, and it removes a whole class of "the button doesn't work" bug reports that were never really bugs — just design assumptions meeting reality. If you want a hand auditing an existing product, that's the kind of work our UX and front-end team does most weeks.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Dark Mode Isn't a Color Flip: What Actually Breaks When You Ship It
Most dark mode implementations are a broken CSS variable swap held together with hope. Here's what breaks in production — shadows, images, brand color, contrast — and how to build a dark theme that doesn't feel like a bug.
Stop Animating Everything: A Motion Budget That Respects Users and CPUs
Most product UIs animate too much, too long, and at the wrong easing. Here's the motion budget we use to keep interfaces feeling fast, accessible, and intentional.
Form Validation Timing: The UX Decision That Breaks Most Sign-up Flows
Validating on every keystroke feels responsive but punishes users mid-thought. Validating only on submit feels rude. Here's the timing model we use to make forms feel calm and competent.
