A deliberate hover or focus state answers one question instantly: what will happen if I press this, and is this the thing I have selected? It gets there by changing one or two things, responding fast, and staying visible to people who never touch a mouse.
What is the difference between hover, focus and focus-visible?
These three selectors look similar and behave very differently.
/* pointer is over the element, whether or not it is focused */
.row:hover { background: #f1f5f9; }
/* the element holds focus, however it got focus */
.row:focus { outline: 2px solid #2563eb; }
/* the element holds focus and the browser thinks a ring should show */
.row:focus-visible { outline: 2px solid #2563eb; }
:hover only exists for pointing devices, so it never fires on a phone. :focus fires whenever an element receives focus, including when a mouse click lands on a button, which is why a naive focus style leaves a ring on the button a user just clicked. :focus-visible is the careful version: the browser shows the indicator when the user is navigating by keyboard, and leaves it out for a pointer click.
There is also :focus-within, which matches an element that contains the focused element. It is the hook for styling a whole field group when one of its inputs takes focus.
How do you build a focus ring people can actually see?
The ring has to be visible against whatever is behind it, and on a mixed page that means more than one colour. The ring is built from two layers: a coloured box shadow that spreads out from the element's edge, and a white outline offset further out. One of the two always contrasts with whatever is behind the control.
:where(a, button, input, select, textarea, summary):focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1d4ed8;
}
The outline property is the right tool because it does not affect layout, it follows the border radius in current browsers, and it is drawn outside the element so it is never hidden by an overflowing child. The outline-offset creates the gap that keeps the ring off the text. Putting the element list inside :where() means that part of the selector contributes no specificity at all, so only the :focus-visible part counts and your own component styles can still override the ring when they need to.
Removing the outline without putting something equally visible in its place is the most common accessibility regression in shipped code. If you want a custom look, replace it, do not delete it.
What makes a hover state feel deliberate?
Three things separate a considered hover from a distracting one. It responds quickly, usually within 150 to 200 milliseconds, because the pointer has already arrived and a slow fade feels broken rather than smooth. It changes colour, brightness or a small transform, and avoids anything that reflows the page, because a link that nudges its neighbours when you pass over it is the classic example of a hover state that is too much. And it is reversible, so moving the pointer away returns the element to where it started without a flicker.
.action {
background: #ffffff;
color: #1d4ed8;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 0.5rem 0.9rem;
transition: background-color 160ms ease, border-color 160ms ease,
transform 160ms ease;
}
.action:hover { background: #eff6ff; border-color: #1d4ed8; }
.action:active { transform: translateY(1px); }
@media (hover: none) {
.action:hover { background: #ffffff; border-color: #cbd5e1; }
}
The @media (hover: none) block matters more than it looks. On a touch screen, a tap can leave the element in its hover state until the next tap elsewhere, so a hover background that was meant as a passing effect sticks around and confuses the reader. Restricting hover styling to devices that genuinely hover avoids that entirely.
How do you cover hover, focus and pressed states together?
Grouping the selectors keeps the response consistent and means you write the visual change once.
.action:hover,
.action:focus-visible {
background: #eff6ff;
border-color: #1d4ed8;
}
.action:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1d4ed8;
}
The order matters here. Both rules match the same element when it is focused by keyboard, so the later one wins for any property the two share, and in this case they set entirely different properties. The result is that the ring is drawn alongside the hover background rather than replacing it, which is what you want: keyboard users get the same emphasis as pointer users, plus the ring.
How do you build a switch with real states?
A toggle is a good exercise because it needs three distinct states — off, hover and on — and it must be operable from the keyboard without any script at all.
<label class="switch">
<input type="checkbox" class="switch__input">
<span class="switch__track" aria-hidden="true"></span>
<span class="switch__label">Email me about new courses</span>
</label>
.switch {
display: inline-flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
}
.switch__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.switch__track {
width: 44px;
height: 24px;
border-radius: 999px;
background: #94a3b8;
position: relative;
transition: background-color 180ms ease;
}
.switch__track::after {
content: "";
position: absolute;
inset-block-start: 3px;
inset-inline-start: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ffffff;
transition: translate 180ms ease;
}
.switch__input:checked + .switch__track { background: #1d4ed8; }
.switch__input:checked + .switch__track::after { translate: 20px 0; }
.switch__input:focus-visible + .switch__track {
outline: 2px solid #ffffff;
outline-offset: 2px;
box-shadow: 0 0 0 4px #1d4ed8;
}
The input itself is visually hidden but still focusable and still in the accessibility tree, and the ring is drawn on the track so it is always visible at a useful size. The thumb moves with translate rather than by changing its position, which keeps the movement cheap. Because the control is a real checkbox inside a label, clicking the text toggles it and the keyboard works without any extra code.
Do not forget the disabled state either. A disabled control should look dimmed, should not respond to hover, and needs cursor: not-allowed so the reason is obvious.
Practical rules
- Use
:focus-visiblefor keyboard rings and keep:hoverfor pointer feedback, so pointer users do not get stray rings. - Replace the outline, never remove it, and check the ring against the lightest and darkest parts of the page.
- Respond within about 200 milliseconds and change colour or a transform rather than the layout.
- Guard hover styles with
@media (hover: hover), or reset them under@media (hover: none), so they do not stick after a tap. - Group hover and focus selectors so the emphasis matches, and let the focus rule come last so its ring survives.
- Give every interactive element a pressed state and a disabled state, not just a hover state.
Try it yourself: Build a settings row for a dashboard with a label, a toggle switch and a save button. Give the toggle the three states above, give the button a hover background, an active press of one pixel and a focus-visible ring, and add a disabled variant of the save button that is dimmed and set to cursor: not-allowed. Then tab through the row and confirm that the ring appears on the toggle and the button, and that clicking them with a mouse does not leave a ring behind.