Hover, Focus, and Active States
The Problem
Interactive elements need visual feedback for hover, focus, and active states. AI agents commonly add :hover styles that create sticky hover states on touch devices, omit :focus and :focus-visible styling (breaking keyboard accessibility), and apply identical styles to all three states when they should be distinct. The result is an interface that works with a mouse but frustrates touch users and keyboard navigators.
The Solution
Style each interaction state with purpose, use @media (hover: hover) to scope hover effects to devices that support them, and use :focus-visible instead of :focus for keyboard-only focus indicators.
The Three States
:hover— A pointing device is over the element (mouse, trackpad). Does not apply reliably on touch devices.:focus— The element is focused, whether by mouse click, keyboard tab, or programmatic focus.:focus-visible— The element is focused and the browser determines a visible indicator is appropriate (typically keyboard navigation). Mouse clicks on buttons do not trigger:focus-visible.:active— The element is being activated (mouse button held down, finger pressing on touch).
Code Examples
Basic Button States
.button {
background-color: var(--color-primary, #2563eb);
color: white;
border: 2px solid transparent;
padding: 0.625rem 1.25rem;
border-radius: 0.375rem;
cursor: pointer;
transition: background-color 0.15s ease, transform 0.1s ease;
}
/* Hover: only on devices that support it */
@media (hover: hover) {
.button:hover {
background-color: var(--color-primary-dark, #1d4ed8);
}
}
/* Focus-visible: keyboard focus indicator */
.button:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: 2px;
}
/* Active: pressed state */
.button:active {
transform: scale(0.97);
}
Link States (LVHA Order)
Link pseudo-classes should follow the LVHA order to avoid specificity conflicts:
a:link {
color: var(--color-link, #2563eb);
text-decoration: underline;
text-underline-offset: 0.2em;
}
a:visited {
color: var(--color-link-visited, #7c3aed);
}
@media (hover: hover) {
a:hover {
color: var(--color-link-hover, #1d4ed8);
text-decoration-thickness: 2px;
}
}
a:focus-visible {
outline: 2px solid var(--color-link, #2563eb);
outline-offset: 2px;
border-radius: 2px;
}
a:active {
color: var(--color-link-active, #1e40af);
}
Card with Hover Effect (Touch-Safe)
.card {
background: var(--color-surface, #ffffff);
border-radius: 0.5rem;
border: 1px solid var(--color-border, #e5e7eb);
padding: 1.5rem;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
/* Only apply hover elevation on hover-capable devices */
@media (hover: hover) {
.card:hover {
box-shadow: 0 4px 16px rgb(0 0 0 / 0.1);
transform: translateY(-2px);
}
}
/* Keyboard focus */
.card:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: 2px;
}
Focus-Visible vs. Focus
/* Remove default focus ring for mouse users */
.interactive:focus {
outline: none;
}
/* Show focus ring only for keyboard users */
.interactive:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: 2px;
}
A safer approach that does not rely on removing :focus styles entirely:
/* Visible focus ring for keyboard navigation */
.interactive:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: 2px;
}
/* Subtle focus style for mouse clicks (if desired) */
.interactive:focus:not(:focus-visible) {
outline: none;
}
Touch vs. Mouse Input Detection
/* Base interactive styles */
.nav-link {
padding: 0.5rem 1rem;
color: var(--color-text);
text-decoration: none;
}
/* Hover effects only for precise pointers */
@media (hover: hover) and (pointer: fine) {
.nav-link:hover {
background-color: var(--color-surface-hover, #f3f4f6);
}
}
/* Larger touch targets for coarse pointers */
@media (pointer: coarse) {
.nav-link {
min-height: 44px;
display: flex;
align-items: center;
padding: 0.75rem 1rem;
}
}
Form Input Focus States
.input {
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
/* All focus (mouse and keyboard) gets a border change */
.input:focus {
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15);
outline: none;
}
For form inputs, using :focus (not :focus-visible) is usually correct because users need to see which input they are typing into, regardless of how they focused it.
Common AI Mistakes
- Adding
:hoverwithout@media (hover: hover): Hover effects persist as "sticky" on touch devices after a tap, confusing users. - Removing
:focusoutlines without replacement: Writingoutline: noneon:focuswithout providing any visible focus indicator, making the page inaccessible to keyboard users. - Using
:focusinstead of:focus-visible: Showing a focus ring on every mouse click (buttons, cards) when only keyboard focus needs a visible indicator. - Applying identical styles to all states: Making
:hover,:focus, and:activelook the same, which removes meaningful visual feedback about the interaction type. - Forgetting the LVHA order: Writing
:hoverbefore:visited, causing specificity conflicts in link styling. - Not testing on touch devices: Assuming hover works everywhere and never verifying the interaction on mobile.
- Using
cursor: pointeron everything: Addingcursor: pointerto non-interactive elements like divs, which misleads users.
When to Use
:hoverwith@media (hover: hover): Visual enhancements (color shifts, shadows, elevation) that only make sense with a mouse or trackpad.:focus-visible: Keyboard focus indicators on buttons, links, and custom interactive elements.:focus: Form inputs where all focus types need a visible indicator.:active: Pressed/tapped feedback (scale, color change) for buttons and interactive elements.@media (pointer: coarse): Increasing touch target sizes and padding for touch devices.
Tailwind CSS
Tailwind provides hover:, focus:, focus-visible:, and active: variant prefixes that map directly to CSS pseudo-classes.