Hover States Are a Lie on Touch: Designing Interaction for Hybrid Devices
Hover was designed for a world with one input device. In 2026, most users have three. Here's how to design interaction states that don't quietly break on tablets, foldables, and Windows touchscreens.
Hover is the interaction pattern designers keep pretending still works everywhere. It doesn't. On a Surface, an iPad with a trackpad, a foldable in tent mode, or a Chromebook that switches inputs mid-session, hover states range from mildly annoying to actively broken — tooltips that stick, dropdowns that require two taps, cards that reveal actions no thumb can reach.
This isn't a mobile-vs-desktop problem anymore. It's a hybrid problem, and the fix isn't "remove hover." It's designing interaction states that adapt to the input device currently in the user's hand.
The actual problem: input is no longer a device property
For a long time we treated input like a screen-size proxy. Small screen? Touch. Big screen? Mouse. That heuristic was always shaky, and in 2026 it's just wrong.
A single user session on a modern laptop might involve:
- Touchscreen taps while the lid is folded back
- Trackpad hover the next minute
- A stylus for annotation
- A Bluetooth mouse when they dock
The browser knows this. Your design system probably doesn't. Most component libraries still bake :hover styles into buttons, cards, and menus as if the pointer is a permanent fixture. Then a QA engineer files a ticket that says "tap-to-reveal menu requires two taps on iPad" and everyone shrugs because it's "a mobile issue."
It's not. It's a design system issue.
Why the two-tap bug happens
On touch devices, the first tap on an element with :hover styles triggers the hover state. The second tap triggers the click. This is browser behaviour by design — it lets touch users "preview" hover content. But when your primary action is hidden behind hover (say, a card's edit button), you've just doubled the interaction cost for half your users.
Stop using :hover as a bare selector
The single highest-leverage change you can make today: wrap every hover style in a @media (hover: hover) query. This is a CSS media query that only applies when the primary input device can actually hover.
/* Don't do this */
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
/* Do this */
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
}
The pointer: fine bit adds a second guard: it checks that the pointer is precise (mouse, trackpad, stylus) rather than coarse (finger). Combined, they mean "this device can hover AND has a precise pointer." That's the audience your hover styles were designed for.
In Tailwind, you can wire this into your config so the whole team gets it for free:
// tailwind.config.js
module.exports = {
theme: {
extend: {
screens: {
'hover-hover': { raw: '(hover: hover) and (pointer: fine)' }
}
}
}
}
Then in components:
<button className="bg-slate-900 hover-hover:hover:bg-slate-700">
Save changes
</button>
It's verbose, but it's honest. And you can wrap it in a hoverable: variant helper if the ergonomics bother your team.
Rethink what hover was actually for
Once you audit your app, you'll find hover doing three very different jobs:
- Affordance — telling the user "this thing is clickable" (button color shifts, link underlines)
- Disclosure — revealing hidden content (tooltips, action menus on cards)
- Preview — showing what will happen (link previews, image zoom)
Each one needs a different fallback for touch.
Affordance: just make it visible
If hover is your only signal that something is interactive, you have a bigger problem than input devices — you have an accessibility problem. Buttons should look like buttons without needing a mouse. Links should be underlined or otherwise visually distinct from body text. Hover can enhance the affordance, but it can't be the affordance.
A reasonable rule: if you removed all :hover styles from your app tomorrow, could a first-time user still figure out what's clickable? If no, fix that first.
Disclosure: this is the dangerous one
Hover-to-reveal is the pattern that breaks hardest on touch. Card action menus, table row buttons, "delete" icons that only appear on hover — all of these become invisible or require multiple taps.
The fixes, in rough order of preference:
- Always show the affordance on touch. If the user has a coarse pointer, show the actions permanently. Yes, the UI is denser. That's the correct tradeoff.
- Use a long-press or explicit menu button. A three-dot menu button is not glamorous, but it works on every input device and every assistive technology.
- Swipe actions for lists. Native-feeling on mobile, but you'll need a discoverable hint (a subtle chevron or first-run overlay) or users won't find them.
Here's the pattern we reach for most often:
.card-actions {
opacity: 1; /* visible by default on touch */
}
@media (hover: hover) and (pointer: fine) {
.card-actions {
opacity: 0;
transition: opacity 150ms ease;
}
.card:hover .card-actions,
.card:focus-within .card-actions {
opacity: 1;
}
}
Note the :focus-within — hover states should always have a keyboard equivalent, and this is the cheapest way to add one.
Preview: consider skipping it entirely
Hover previews (Wikipedia-style link cards, Figma's hover-to-see-thumbnail) are lovely on a trackpad and useless on touch. You have two honest options: replicate them with a tap-and-hold gesture (expensive to build well), or accept that touch users won't get previews and make sure the underlying links carry enough context on their own.
We usually go with option two. The engineering cost of a robust cross-input preview system rarely pays off.
Motion needs the same treatment
While you're auditing hover, audit motion too. A hover-triggered animation on a card feels premium on desktop. On a touchscreen, the same card jumps around every time a finger passes near it (or worse, sticks in the hovered state until the user taps somewhere else).
Keep motion ratios subtle — we generally cap hover translations at 2 – 4px and scale changes at 1.02 – 1.05x — and gate them behind the same (hover: hover) query. Then wrap everything in prefers-reduced-motion for users who've asked for less movement:
@media (hover: hover) and (pointer: fine) {
@media (prefers-reduced-motion: no-preference) {
.card {
transition: transform 200ms ease;
}
.card:hover {
transform: translateY(-2px);
}
}
}
Nested media queries are supported everywhere modern now. Use them.
Detecting input changes at runtime
CSS media queries handle 90% of cases, but sometimes you need JavaScript to know the current input mode — for example, to render a different component tree entirely.
const hoverQuery = window.matchMedia('(hover: hover) and (pointer: fine)');
function updateInputMode(e) {
document.documentElement.dataset.pointer = e.matches ? 'fine' : 'coarse';
}
updateInputMode(hoverQuery);
hoverQuery.addEventListener('change', updateInputMode);
On a hybrid device, this fires when the user plugs in a mouse or detaches a keyboard. React to it. Or better: track the last used input type via pointerdown events, since matchMedia only reports the primary pointer, not the one currently in use.
That last-used-input approach is what Microsoft uses internally for Fluent UI, and it's the most accurate signal you can get in a browser today.
A quick audit you can run this week
Grab a tablet with a keyboard case, or just open your app on a Windows touchscreen laptop. Then:
- Tap every card, row, and menu with a finger. Count the elements that require two taps or reveal hidden content.
- Attach a mouse. Do the interactive elements now feel different — do things move, appear, or highlight? Good. Make sure those enhancements are gated properly.
- Detach the mouse. Do any hover states get stuck? That's your two-tap bug in the wild.
- Open DevTools, toggle device mode, and check that
(hover: none)styles look intentional, not like a broken desktop layout.
Most teams find between 15 and 40 hover-related issues on a first pass. The fixes are usually small — a media query wrapper, a :focus-within addition, a decision to just show the button.
Where we'd start
If your design system predates 2023, there's a good chance every interactive component has bare :hover styles. Don't try to fix them all at once. Start with the components that carry primary actions — cards with edit menus, table rows, navigation dropdowns — and get those adaptive first. Add a hoverable: variant to your Tailwind config (or the equivalent in your CSS-in-JS layer) so new components default to the right pattern.
Then write it down. A one-page "how we handle hover" doc in your design system's contribution guide will save more bugs than any linter. If you want a hand auditing your component library or rebuilding a design system that respects hybrid input, our design and engineering team does this work often — usually as part of a broader accessibility pass.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Disabled Buttons Are a UX Smell: What to Do Instead
Disabled submit buttons feel tidy in Figma and hostile in production. Here's why we've stopped shipping them, and the patterns we reach for instead.

Modals Are a Routing Problem: Rethinking Dialogs in 2026
Most modal bugs aren't CSS bugs. They're routing bugs, focus bugs, and state bugs pretending to be design decisions. Here's how to actually ship dialogs that behave.

Form Field Errors: Why Yours Are Failing Users and How to Fix Them
Inline validation looks helpful on the Figma frame and hostile in production. Here's how to design error states that guide users through a form instead of punishing them for typing.
