Hover States Are a Lie on Touch: Designing Interactions That Work Everywhere
Hover is a desktop luxury that quietly breaks on half your traffic. Here's how to design interactions that survive touch, keyboard, stylus, and the weird in-between devices your analytics forgot about.
Hover is the interaction pattern designers still design for and engineers still ship, even though roughly half of web traffic can't use it. Tooltips that appear on mouseover, dropdowns that expand when you approach them, cards that reveal a delete button only when hovered — every one of these becomes a puzzle on a phone, a tablet, or a Surface being used as a tablet that afternoon.
This isn't a plea to remove hover. It's a walkthrough of how to design and build interactions that behave correctly regardless of what the user is pointing with — including the devices that switch mid-session.
The problem isn't touch, it's assumption
Most hover bugs don't come from touch devices being "broken." They come from designers and engineers assuming input modality is fixed and known. It isn't.
A modern laptop can have a touchscreen, a trackpad, a mouse plugged in, and a stylus paired over Bluetooth — simultaneously. An iPad can have a Magic Keyboard with a trackpad. A phone can be mirrored to a monitor with a Bluetooth mouse. The user might switch inputs three times in a session without you knowing.
So the real question isn't "is this a touch device?" It's: does the current input support hover, and does it have fine-grained pointing?
What browsers actually give you
CSS has had the tools for this since around 2018, and they're now reliable enough to build on:
/* Device can hover reliably (mouse, trackpad) */
@media (hover: hover) and (pointer: fine) {
.card:hover .card__actions {
opacity: 1;
}
}
/* Primary input is coarse (finger) — no hover */
@media (hover: none) or (pointer: coarse) {
.card__actions {
opacity: 1; /* always visible */
}
}
The key insight: hover: hover tells you the primary input supports hover. It doesn't guarantee the user is using it right now, but it's the best signal the platform gives you without JavaScript event sniffing.
Three patterns that survive input switching
After enough production support tickets, we've settled on three patterns that hold up across input types. Pick one per interaction; don't mix them on the same element.
1. Progressive disclosure with a persistent affordance
The classic hover-to-reveal pattern (think GitHub's row actions) works fine if you keep a visible affordance for coarse pointers. A small three-dot menu icon that's always visible on touch, but which expands into full inline actions on hover.
.row__menu-trigger { display: block; }
.row__actions-inline { display: none; }
@media (hover: hover) and (pointer: fine) {
.row__menu-trigger { display: none; }
.row__actions-inline {
display: flex;
opacity: 0;
transition: opacity 120ms ease;
}
.row:hover .row__actions-inline { opacity: 1; }
}
On touch, users tap the three dots and get a sheet. On desktop, they hover and get instant inline actions. Neither group is second-class.
2. Tap-to-reveal, tap-outside-to-dismiss
For tooltips and hint text, replace pure hover with a click/tap trigger that also responds to hover on capable devices. The Radix and Ark UI primitives handle this well, but you can do it yourself:
function InfoTip({ label, children }) {
const [open, setOpen] = useState(false);
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)');
return (
<span
onClick={() => setOpen(o => !o)}
onMouseEnter={canHover ? () => setOpen(true) : undefined}
onMouseLeave={canHover ? () => setOpen(false) : undefined}
onBlur={() => setOpen(false)}
tabIndex={0}
role="button"
aria-expanded={open}
>
{children}
{open && <span role="tooltip">{label}</span>}
</span>
);
}
Two things worth noting. First, the element is focusable and has a role, so keyboard users can trigger it with Enter or Space. Second, hover is additive — the base behavior works everywhere; hover is just a nicer experience on top.
3. No-hover-required by default
The safest pattern: don't hide anything behind hover in the first place. Reserve hover for pure feedback — subtle background shifts, cursor changes, underline animations — never for content or actions.
This sounds restrictive but it's usually the right call for anything below a power-user tool. Marketing sites, e-commerce, dashboards used by non-technical staff — hide-on-hover patterns generate support tickets in all of them.
The :hover sticky bug on iOS
A classic gotcha: on iOS Safari, tapping an element with a :hover style can leave it in the hovered state until you tap somewhere else. Users see a button that looks stuck.
The fix is the same media query approach — scope your hover styles so they never apply on coarse pointers:
.btn { background: var(--btn-bg); }
@media (hover: hover) and (pointer: fine) {
.btn:hover { background: var(--btn-bg-hover); }
}
.btn:active { background: var(--btn-bg-active); }
On touch, users get the :active state during the tap and nothing sticks around after. On desktop, they get the full hover experience.
Tailwind and design tokens
If you're on Tailwind 3 or later, the hover: variant already respects (hover: hover) when you enable the hoverOnlyWhenSupported future flag. In v4 this is the default behavior. So hover:bg-slate-100 won't apply on touch devices, and the iOS sticky bug largely disappears.
For a token-driven system, we tend to define hover as a semantic state rather than a color:
{
"color": {
"surface": {
"base": "#ffffff",
"hover": "#f4f6f8",
"pressed": "#e6eaef"
}
}
}
Then the component decides when to apply hover — which might be never, on coarse-pointer devices. This keeps the token layer honest: it describes visual states, not input assumptions.
If you're setting up a token pipeline from scratch, our team wrote about the naming conventions we've landed on over on the 72Technologies blog.
Testing across input types
You can't rely on Chrome DevTools' device emulation for this. It fakes viewport and touch events but doesn't accurately simulate the pointer media queries in all cases, and it definitely doesn't catch the hybrid input scenarios.
A minimum viable test matrix:
- Desktop with mouse — the default assumption, easy.
- Desktop with touchscreen — a Windows laptop with a touchscreen is the most-forgotten case. Hover works with the trackpad; tapping the screen should also work.
- Tablet with keyboard/trackpad — iPad Pro with Magic Keyboard.
hover: hoveris true when the trackpad is active. - Phone — obvious.
- Keyboard only — unplug the mouse, tab through everything, make sure nothing is hover-only.
The keyboard test is the one that catches the most real accessibility bugs. If your dropdown only opens on hover, keyboard users can't reach it. If your tooltip only shows on hover, screen reader users may miss critical context.
A quick audit script
Drop this in the console on any page to find suspicious hover-only interactions:
Array.from(document.styleSheets)
.flatMap(s => { try { return Array.from(s.cssRules); } catch { return []; } })
.filter(r => r.selectorText?.includes(':hover'))
.filter(r => !r.parentRule?.conditionText?.includes('hover: hover'))
.map(r => r.selectorText);
It won't catch everything — JS-driven hover handlers slip through — but it flags CSS hover rules that aren't scoped to hover-capable devices. In a mid-sized app we ran this on, it returned 140+ selectors on the first pass.
Where we'd start
If you're auditing an existing product, do this in order:
- Run the console script above. Wrap every unscoped
:hoverrule in@media (hover: hover) and (pointer: fine)unless it's genuinely decorative (like a subtle color shift). - Find every UI element that hides content or actions behind hover. Add a persistent affordance — a chevron, a menu icon, a visible label — so touch and keyboard users have a path in.
- Enable Tailwind's
hoverOnlyWhenSupportedflag if you're on v3, or upgrade to v4 where it's default. - Test with a real touchscreen laptop, not just DevTools. This is the single highest-value test you can do, and most teams skip it.
Hover isn't going away. But treating it as the primary interaction, instead of a progressive enhancement, is a habit worth breaking. Design for the coarse pointer first, layer hover on top, and your product stops silently failing for the users you can't see in your dev environment.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Figma Variables to Tailwind Tokens: A Workflow That Actually Survives Handoff
Design tokens die at the handoff boundary. Here's the Figma Variables → Tailwind pipeline we use so color, spacing, and radius changes make it to production without a designer filing a Jira ticket.

Empty States Are a Product Decision, Not a Design Afterthought
Most empty states are decorative apologies. The good ones sell the product, teach the mental model, and reduce support tickets. Here's how we design and build them without shipping cartoon mascots nobody asked for.

Skeleton Screens Are Lying to Your Users: When to Use Them and When to Stop
Skeleton screens became the default loading pattern, and most teams use them wrong. Here's when they help perceived performance, when they hurt it, and what to reach for instead.
