postcss-pseudo-hover
v1.2.0
Published
PostCSS plugin that extracts and wraps rules containing `:any-hover` pseudo-classes in `@media (any-hover: hover) {}` media queries
Maintainers
Readme
postcss-pseudo-hover
PostCSS plugin that extracts and wraps rules containing :any-hover pseudo-classes in @media (any-hover: hover) {} media queries, then restores :any-hover back to :hover.
/* Before */
.foo:any-hover {
text-decoration: underline;
}
/* After */
@media (any-hover: hover) {
.foo:hover {
text-decoration: underline;
}
}Why?
Mobile devices use touchscreens and generally do not have a mouse hover capability. To work around this, mobile browser vendors simulate hover behavior: when a user touches an element, the browser places an invisible cursor on it and applies :hover styles. Even after the user lifts their finger, the hover styles persist until they tap somewhere else on the screen.
This default behavior often looks awkward — a button may remain highlighted long after the user's finger has left the screen. In response, some UX designers have begun advocating to abandon :hover styles entirely in the name of "mobile-first" design. This is an overcorrection. Hover styles are genuinely useful on devices that support them (desktops with mice, laptops with trackpads, tablets with styluses). The problem isn't :hover — it's applying :hover indiscriminately regardless of input capability.
This plugin solves the problem at the CSS level: it wraps hover rules inside @media (any-hover: hover), so browsers will only apply them when the user's device actually supports hovering. On touch-only devices, these styles are never applied, and there is no lingering highlight. On pointer-capable devices, hover styles work as intended. No JavaScript detection, no sniffing user agents — just standard CSS media queries.
Installation
Add postcss-pseudo-hover to your project:
# npm
npm install --save-dev postcss-pseudo-hover
# yarn
yarn add --dev postcss-pseudo-hover
# pnpm
pnpm add --save-dev postcss-pseudo-hoverUsage
Add postcss-pseudo-hover to your PostCSS plugins:
// postcss.config.js
import postcssPseudoHover from "postcss-pseudo-hover";
export default {
plugins: [postcssPseudoHover()],
};Then write :any-hover wherever you would normally write :hover:
.button:any-hover {
background-color: #0056b3;
}
.link:any-hover {
text-decoration: underline;
}After processing, the output becomes:
@media (any-hover: hover) {
.button:hover {
background-color: #0056b3;
}
.link:hover {
text-decoration: underline;
}
}On touch-only devices, browsers ignore the entire block. On pointer-capable devices, hover styles apply normally.
Use it with Stylelint
Stylelint users: If you use Stylelint, you may need to add any-hover (or your custom pseudo-class) to the selector-pseudo-class-no-unknown.ignorePseudoClasses configuration to avoid warnings about unknown pseudo-classes.
Example:
import type { Config } from "stylelint";
export default {
rules: {
"selector-pseudo-class-no-unknown": [true, {
// Put you own selector pseudo class here.
"ignorePseudoClasses": ["any-hover"],
}],
}
} satisfies Config;
Options
selector
Specifies the pseudo-class the plugin should look for and process. The leading colon is optional (:any-hover and any-hover are equivalent).
| Type | Default |
|------|---------|
| string | ":any-hover" |
Built-in choices:
:any-hover(default) — Recommended. Only rules explicitly using:any-hoverare processed. Using:hoverelsewhere leaves those rules untouched, so you have full control on a per-rule basis. This gives you an "escape hatch" for rare cases where you want:hoverto apply unconditionally.:hover— Processes all:hoverpseudo-classes globally. Convenient, but there is no way to opt out a specific rule.- Custom — Any valid pseudo-class name (e.g.
:my-hover,:hover-device). It must be at least one uppercase and lowercase letter, number, underscore, hyphen, and cannot start with a number.
Why not make :hover the default? Because processing every :hover removes your ability to write a rule that applies :hover regardless of device. With :any-hover, you decide exactly which rules go through the media query and which don't.
// Process all :hover selectors
postcssPseudoHover({ selector: ":hover" });
// Use a custom pseudo-class
postcssPseudoHover({ selector: ":my-fancy-hover" });mediaQuery
Specifies the media query feature that wraps the processed rules. Accepts either a single feature string or an array of feature strings (joined with and).
| Type | Default |
|------|---------|
| string \| string[] | "any-hover: hover" |
Built-in choices (for individual features):
| Value | Meaning |
|-------|---------|
| any-hover: hover | The device has at least one input mechanism capable of hovering |
| hover: hover | The primary input mechanism is capable of hovering |
| any-hover | Same as any-hover: hover, but less future-proof if the spec adds new values |
| hover | Same as hover: hover, but less future-proof if the spec adds new values |
| any-pointer: fine | The device has at least one input mechanism capable of precise pointing |
| pointer: fine | The primary input mechanism is capable of precise pointing |
| Custom | Any string else (e.g. a hypothetical future media feature) |
Array usage: When you pass an array, the features are combined with and. For example, ["any-hover: hover", "any-pointer: fine"] produces @media (any-hover: hover) and (any-pointer: fine). This is useful when you want to require multiple conditions to be met simultaneously.
Why not make hover: hover the default? hover: hover only checks the primary input mechanism. Consider a laptop with both a touchscreen and a trackpad — the primary mechanism might be the touchscreen (hover: none), even though the trackpad can hover just fine. any-hover: hover checks all available input mechanisms, which is more accurate for this use case.
// Check only the primary input device
postcssPseudoHover({ mediaQuery: "hover: hover" });
// Use a future-proof or custom media feature
postcssPseudoHover({ mediaQuery: "-very-cool-hover: excellent" });
// Combine multiple features with AND
postcssPseudoHover({ mediaQuery: ["hover: hover", "pointer: fine"] });escapeMediaQueries
Specifies additional media queries that, if the :any-hover selector is already nested inside, will prevent the plugin from creating a new media query wrapper (avoiding redundant or recursive nesting).
| Type | Default |
|------|---------|
| string[] | [] |
The following values are always included automatically — you do not need to add them:
any-hover: hoverhover: hoverany-hoverhoverany-pointer: finepointer: fine- The current value(s) of the
mediaQueryoption (each array element individually)
// Also skip wrapping when already inside @media (max-width: 768px)
postcssPseudoHover({ escapeMediaQueries: ["max-width: 768px"] });This is useful when you have your own media query structure and don't want double-wrapping in certain contexts.
withFocusVisible
When enabled, the plugin creates an additional copy of each processed rule outside the media query, with :any-hover replaced by :focus-visible instead of :hover. This allows :hover and :focus-visible to share the same styles, providing keyboard users with a consistent visual experience.
| Type | Default |
|------|---------|
| boolean | false |
/* Input */
.foo:any-hover {
text-decoration: underline;
}
/* Output with withFocusVisible: true */
@media (any-hover: hover) {
.foo:hover {
text-decoration: underline;
}
}
.foo:focus-visible {
text-decoration: underline;
}Rules that are already inside an escape-listed @media query (see escapeMediaQueries) will not generate a :focus-visible copy — only :any-hover → :hover restoration is performed.
postcssPseudoHover({ withFocusVisible: true });How It Works
- The plugin walks each CSS rule and checks if the selector contains the configured pseudo-class (e.g.
:any-hover). - If the rule is already nested inside an escape-listed
@mediaquery, it simply restores:any-hover→:hoverwithout wrapping (and without creating a:focus-visiblecopy, regardless ofwithFocusVisible). - Otherwise, it splits selectors into "hover" and "non-hover" groups:
- Hover selectors are moved into a new
@mediaquery (with:any-hoverrestored to:hover). - Non-hover selectors stay in place (the original rule is replaced if only non-hover selectors remain).
- Hover selectors are moved into a new
- If
withFocusVisibleis enabled, an additional copy of the hover rule is placed outside the media query with:any-hoverreplaced by:focus-visible. - Nested
:any-hoverpseudo-classes (e.g. inside:is(),:not(),:has()) are properly handled.
Comparison with Similar Plugins
postcss-hover
This plugin deletes all :hover pseudo-classes entirely, designed for workflows that build separate stylesheets for desktop (with :hover) and mobile (without :hover). This approach:
- Does not support responsive design — a single page cannot adapt to both touch and pointer input.
- Increases maintenance burden by requiring separate builds.
- Is archived (no longer maintained).
postcss-hover-prefix
This plugin removes :hover from selectors and prepends a configurable prefix class as an ancestor selector (e.g. .foo:hover → .supports-hover .foo). This is essentially the same strategy as postcss-hover-media-feature's fallback mode, using class-based detection rather than media queries. Like postcss-hover, it does not use media queries and requires JavaScript to toggle the prefix class.
postcss-hover-media-feature
This project's code was partly based on this plugin.
It wraps :hover rules in @media (hover: hover) and does not allow customizing the pseudo-class or media query. It also provides fallback options: when enabled, it will use class-based detection rather than media queries. Refer to the Fallback for legacy browsers section for detailed information.
You can configure
postcss-pseudo-hoverto behave identically topostcss-hover-media-featureby setting:postcssPseudoHover({ selector: ":hover", mediaQuery: "hover: hover" });
postcss-plugin-hover
This plugin introduces a custom @hover at-rule. A block inside @hover { ... } behaves like using &:any-hover { ... } with this plugin — the at-rule is converted to a :hover selector wrapped inside @media (hover: hover) and (pointer: fine), and an identical copy is placed outside the media query with :hover replaced by :focus-visible. It offers no customization options.
You can achieve equivalent behavior with
postcss-pseudo-hoverby setting:postcssPseudoHover({ mediaQuery: ["hover: hover", "pointer: fine"], withFocusVisible: true });
postcss-any-hover
This plugin wraps :hover rules in @media (any-hover: hover). Unlike postcss-hover-media-feature, it uses any-hover instead of hover, but it still operates on every :hover pseudo-class without allowing customization of the selector or media query. It also provides an alsoApplyToFocusVisible option: when enabled, in addition to moving :hover rules into the media query, it duplicates those rules outside the media query and replaces :hover with :focus-visible, so that keyboard users see the same styles.
You can achieve equivalent behavior with
postcss-pseudo-hoverby setting:postcssPseudoHover({ selector: ":hover", mediaQuery: "any-hover: hover", withFocusVisible: alsoApplyToFocusVisible });
Plugin differences
| Feature | postcss-pseudo-hover | postcss-hover-media-feature | postcss-plugin-hover | postcss-any-hover |
|---|---|---|---|---|
| Default pseudo-class | :any-hover(opt-in per rule) | :hover(all-or-nothing) | @hover(opt-in with custom at-rule) | :hover(all-or-nothing) |
| Default media query | any-hover: hover(all inputs) | hover: hover(primary only) | hover: hover(primary only) | any-hover: hover(all inputs) |
| Custom pseudo-class | ☑️ | ❎ | N/A | ❎ |
| Custom media query | ☑️ | ❎ | ❎ | ❎ |
| :focus-visible companion | ☑️ (via withFocusVisible) | ❎ | ⚠️ (all-or-nothing) | ☑️ (via alsoApplyToFocusVisible) |
| Fallback for old browsers | ❎ (see below) | ☑️ | ❎ | ❎ |
Unsupported Features (by Design)
Fallback for legacy browsers
postcss-hover-media-feature and postcss-hover-prefix includes a fallback mechanism for browsers that don't support (hover: hover) or (any-hover: hover) media queries: it uses JavaScript to detect touch support, adds a class like supports-hover or supports-touch to the <html> element, and then rewrites the selectors like below.
.foo:hover {
}@media (hover: hover) {
.foo:hover {
}
}.supports-hover .foo:hover {
}html:not(.supports-touch) .foo:hover {
}These media query features have been supported in all major browsers since December 2018 (see MDN). That is over several years ago. I consider a JavaScript-based fallback for such well-established features unnecessary.
:not() special handling
postcss-hover-media-feature skips processing :hover when it appears inside :not() (e.g. :not(:hover) is left outside the media query). This plugin does not implement that behavior. Since :any-hover is an independent pseudo-class, you control it directly:
:not(:any-hover)→ gets wrapped in the media query (becomes:not(:hover)):not(:hover)→ left untouched
Full control, no magic.
Contradictory nesting
If you write a rule like:
@media (any-hover: none) {
.foo:any-hover {
color: red;
}
}It will compile to:
@media (any-hover: none) {
@media (any-hover: hover) {
.foo:hover {
color: red;
}
}
}This nested rule can never match (a device cannot simultaneously have any-hover: none and any-hover: hover). Rather than attempting to detect this contradiction, the plugin leaves it as-is. The correct approach is simply to use :hover directly when you intentionally want hover inside an any-hover: none context:
@media (any-hover: none) {
.foo:hover {
color: red;
}
}