Tailwind hover: 149 Uses, 59 Rules, and Why None Fire on a Phone
Tailwind v4 wraps hover: in a hover media query. Measured in a real browser, plus the recipe, the hover-only overlay bug and a troubleshooting table.
Tailwind's hover: variant applies a utility while the pointer is over an element. In Tailwind v4 it only applies on devices that can hover at all: the compiled CSS wraps every :hover rule in @media (hover: hover). If a hover style "doesn't work", the usual causes are a touch device, a utility that loses a specificity fight, or a parent that never received the group class. This codebase ships 149 hover: utilities and, measured in a real browser, none of them fire on a phone.
What hover: compiles to in v4
Tailwind v3 emitted a bare .hover\:bg-primary-600:hover { … } rule. Tailwind v4 (this repo runs 4.3.1) emits the same selector inside a media query. I counted it in the production stylesheet after npm run build:
| Measured in the built CSS | Count |
|---|---|
| Stylesheet size, raw | 83,719 B |
@media (hover:hover) blocks | 5 |
:hover rules inside those blocks | 59 |
:hover rules outside them | 0 |
Five blocks, not 59, because the minifier merges neighbours: one block holds the plain hover: rules, one the group-hover: rules, one the dark:hover: rules, and so on. The point is the last row. There is no :hover rule on the site that a touch screen can trigger.
That is a deliberate v4 default, and the reason is sticky hover. On a touch screen a tap fires a synthetic :hover that stays until you tap elsewhere, so a "hover" colour would stick to whatever you last touched. Gating on (hover: hover) removes the bug by removing the state.
Prove it in a browser
I ran the built site and drove the home page's primary button (bg-primary text-white hover:bg-primary-600) in two Chromium contexts, one desktop and one phone-emulated with touch.
| Context | (hover: hover) | (pointer: coarse) | Background before | Background after hover / tap |
|---|---|---|---|---|
| Desktop, mouse moved onto the button | true | false | rgb(70, 95, 255) | rgb(54, 65, 245) |
| Phone emulation, button tapped | false | true | rgb(70, 95, 255) | rgb(70, 95, 255) |
On desktop the button moves from primary to primary-600. On the phone the colour never changes, because the rule is not in force. Nothing is broken. The variant did what the media query says.
The recipe this codebase uses
Every call to-action goes through one atom, src/components/atoms/Button.tsx:
const base =
"inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium shadow-xs duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
const variants: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary-600",
outline:
"border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
dark: "bg-gray-900 text-white hover:bg-gray-800",
};
Two details worth copying. The hover shade is one step on the same scale (primary to primary-600), so the change reads as feedback and not as a new colour. And duration-200 is there with no transition class beside it. That works because a CSS element's transition-property defaults to all. The browser confirmed it: the computed transition-property on that button is all. If you want to animate only colour, transition-colors narrows it, but a bare duration-* is enough to make hover: animate.
How the 149 break down by what they change, as counted with grep over src/**/*.tsx (comments included):
| Utility family | Uses |
|---|---|
hover:bg-* | 92 |
hover:text-* | 46 |
hover:underline | 13 |
hover:border-* | 8 |
translate, opacity, visible | 6 |
The dark:hover: variant adds 40 more and group-hover another 24, across 54 files. Colour dominates, which is the safe use: a colour shift is decoration, so losing it on touch costs nothing.
The defect: hover as the only way in
The version of "hover not working" that actually shipped here was not CSS. TemplateCard reveals a blurred "Preview" overlay on the cover image with group-hover/media::
<div className="invisible absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-[rgba(152,162,179,0.32)] opacity-0 backdrop-blur-[15px] duration-200 group-hover/media:visible group-hover/media:opacity-100">
On desktop it works. On a phone the overlay never appears, and invisible also takes its button out of the tab order, so a keyboard user can't reach it either. A card whose only preview route was that overlay would be unreachable on a phone. The fix was to stop treating the overlay as a control. It is now aria-hidden, out of the tab order, and documented in the component as a pointer shortcut. A separate, always-visible Preview button under the card is the real control, at every width. Tailwind group-hover covers the named-group half of that card.
The rule that came out of it: a hover style may decorate or shortcut, but it may not be the only route to an action. Colour changes, underlines and overlays are fine. Navigation and content are not.
The gap this leaves: no pressed state
Counting the other side of the ledger makes the trade visible. The source has 149 hover: utilities, 37 focus-visible: utilities and no active: utility at all (the one grep hit is the word in a comment). Mouse users get a hover shade, keyboard users get a ring, and a touch user gets neither: the button's colour is identical before and after the tap, as the table above shows.
That is a gap, not an accident of the variant. The fix is one more token on the same shade. This is a suggestion, not code that ships here:
primary: "bg-primary text-white hover:bg-primary-600 active:bg-primary-700",
active: applies while the finger is down, on every device, and it is not gated behind (hover: hover). Put it in the variants map of Button and all call sites inherit it, which is the same reason the hover shade lives there.
Restoring the old behaviour
If you need hover: to apply on touch, override the variant in your stylesheet:
@import "tailwindcss";
@custom-variant hover (&:hover);
This repo does not do it, and I would not. With the override, every hover: rule returns to a bare :hover and the sticky-hover problem comes back on phones. Reach for active: when you want touch feedback, since :active applies for the duration of the press.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Hover works on desktop, never on a phone | v4 wraps hover: in @media (hover: hover) | Expected. Use active: for touch feedback |
| Hover works in the browser, not in a touch-emulating DevTools mode | Emulation reports hover: none | Switch the emulated device or test with a real mouse |
| The class is in the markup but nothing changes | The class is built from a string concatenation Tailwind can't scan | Write complete class names; never assemble hover:bg-${color} |
| Another background wins over the hover one | A later or more specific rule overrides it | Put the variant on the element that owns the colour, or check dark: ordering |
| Hover colour jumps with no animation | No duration set | Add duration-200 (the transition property already defaults to all) |
| Child doesn't react to the parent's hover | The parent lacks group, or the child uses a different group name | Add group to the parent, or match group/name with group-hover/name: |
| Hovering a disabled button still shows the hover colour | The disabled state doesn't suppress it | Add disabled:hover:bg-* or disabled:pointer-events-none |
| Hover works in light mode and not in dark | The dark colour has no dark:hover: partner | Add dark:hover:*; this repo has 40 |
Frequently asked questions
Why doesn't Tailwind hover: work on mobile?
Since v4, hover: is wrapped in @media (hover: hover), which is false for touch-first devices. This is by design, to avoid hover states getting stuck after a tap. Use active: or a visible control instead.
Can I use hover: with dark: and group?
Yes, and they stack: dark:group-hover:text-white is valid. The built stylesheet here contains such rules, each inside the same (hover: hover) gate.
Is hover: enough for accessibility?
No. Keyboard users never hover. Pair every interactive hover style with focus-visible: so the state is reachable without a mouse. Tailwind focus-visible shows the ring recipe that sits beside the hover shade in Button.
Should I animate with transition or only duration-*?
duration-* alone animates every property, because the initial transition-property is all. Add transition-colors or transition-transform when you want to limit what animates.
Templates in this post
ASoc Guard (an AI cyber-security marketing site), ASoc Haven (a real-estate listings site with a filterable grid) and ASoc Hearth (an AI smart-home landing page) ship the hover and focus recipe above on their buttons and cards.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For theming those same hover shades in dark mode, see Tailwind dark mode.
