Published
Card hover and flip effects that survive a touchscreen
You build a card that lifts on hover and reveals the description underneath. It is lovely on your laptop. On a phone, the first tap triggers the hover state and does not navigate; the second tap navigates; and the card stays lifted with its description showing after the user has scrolled away. You have not built an effect, you have built a bug that looks like an effect on one class of device.
The fix is not to abandon hover. It is to decide, before writing the CSS, what the effect does when hover does not exist.
Why the state sticks
Mobile browsers emulate hover to keep hover-only interfaces usable. When you tap an element with hover styles, the browser applies :hover, and — because there is no pointer to move away — leaves it applied until you tap somewhere else. On many browsers, the first tap on a hover-styled element is consumed by that emulation rather than passed on as a click.
So a hover effect on touch gives you three symptoms at once: a stuck visual state, a swallowed first tap, and content that is only reachable by a gesture the device cannot make.
Ask the media query, not the screen width
The old approach was to disable hover effects below a breakpoint. That is the wrong axis: a touchscreen laptop is wide and has no reliable hover, a phone connected to a mouse has one, and a tablet has both. Screen width tells you nothing about the input device.
The media features that actually ask the right question are hover and pointer:
@media (hover: hover) and (pointer: fine)— a device with a real pointer that can hover@media (hover: none)— a device that cannot hoverpointer: coarse— a touch or otherwise imprecise input
Put every hover-dependent rule inside the first one. On a touch device the rules simply never apply, so there is nothing to stick:
.card { transition: transform .2s ease, box-shadow .2s ease; }
@media (hover: hover) and (pointer: fine) {
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgb(0 0 0 / .15); }
}
Note that the transition stays outside the query. It belongs to the card, not to the hover, and it is also what animates the focus state.
Decorative or essential
This is the decision that determines everything else.
Decorative effects — a lift, a shadow, a slight scale, a colour shift, an underline sliding in — communicate "this is interactive" and nothing more. They can disappear entirely on touch, because a touch user already knows a card is tappable from tapping it. Wrap them in the hover query and stop thinking about them.
Essential effects reveal information: a description that appears on hover, a "read more" link that fades in, the back of a flipped card. These cannot simply vanish on touch, because then the content is unreachable. You have two honest options:
- Show it always on touch. Inside
@media (hover: none), render the hidden layer visible. The card is taller and less clever, and everything is readable. This is almost always the right answer. - Bind it to a real interaction. Make the reveal a click or a tap on an explicit control, not a hover — a details/summary element, a button, or a checkbox toggle. The effect then works identically for both input types, and hover just previews it.
What you must not do is leave essential content behind a hover on a device without one. That is not a visual regression; it is content that does not exist for half the audience.
Flip cards specifically
A flip is the case where this goes wrong most often, because the back of the card is by definition content hidden behind an interaction. The mechanics are straightforward:
.flip { perspective: 1000px; }
.flip-inner { transform-style: preserve-3d; transition: transform .6s; }
.flip-front, .flip-back { backface-visibility: hidden; }
.flip-back { transform: rotateY(180deg); }
@media (hover: hover) and (pointer: fine) {
.flip:hover .flip-inner { transform: rotateY(180deg); }
}
perspective on the outer element gives the rotation depth — without it the card appears to squash rather than turn. preserve-3d keeps the two faces in the same 3D space instead of flattening them. backface-visibility: hidden is what stops you seeing a mirrored front through the back.
Then, for touch, drive the same transform from a class and toggle it on click, or from :focus-within with a real focusable element inside. Either way the animation is unchanged; only the trigger differs.
Two practical cautions. A flipped card's hidden face is still in the accessibility tree, so screen readers announce both sides at once unless you manage aria-hidden and remove the back face from the tab order while it is turned away. And a flip only works when both faces are the same size — a back face with more text than the front will either clip or force the container taller than the front needs.
Keyboards need the same states
Whatever you do on hover, do on focus. A keyboard user tabbing through a grid of cards has no other way to see where they are:
.card:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
:focus-visible rather than :focus means mouse clicks do not leave a ring behind while keyboard navigation still gets one. If the hover effect reveals content, :focus-within on the card reveals the same thing, so tabbing into the card shows what hovering it shows.
And respect the preference for less motion. A lift is fine; a 600ms 3D rotation is exactly what the setting exists for:
@media (prefers-reduced-motion: reduce) {
.flip-inner, .card { transition: none; }
}
The order to build in
Build the card with no effect at all and make sure it reads correctly — all content visible, tappable, keyboard-reachable. Add the focus state. Then add hover inside @media (hover: hover) and (pointer: fine) as the last layer, as enhancement rather than as structure. Test by tapping, not just by resizing the window, because a narrow desktop window still has a mouse.
The hover card builder and the flip card generator output CSS with the hover query, focus states and reduced-motion block already in place, and the card builder covers the base card the effects sit on.