CSS hover transition

Put the transition property on the resting state, not inside the hover rule. That one placement decides whether the effect eases both ways or snaps back the moment the pointer leaves.

A CSS hover transition works when the transition property sits on the element's resting state, not inside the :hover rule.

/* correct: eases in and out */
.btn {
  background: #1e293b;
  transition: background 160ms ease, transform 160ms ease;
}
.btn:hover { background: #334155; transform: translateY(-1px); }
/* wrong: eases in, snaps back */
.btn { background: #1e293b; }
.btn:hover {
  background: #334155;
  transition: background 160ms ease;
}
A button mid transition as the pointer leaves it, the background still part way between the two colours.
A button mid transition as the pointer leaves it, the background still part way between the two colours.

Why the placement matters

A transition is a property of the state the element is in. When the pointer leaves, the element goes back to the base rule, and the browser looks at that rule for a transition.

If the only transition declaration lives in :hover, that declaration is gone at exactly the moment it would be needed. The return is instantaneous.

There is one deliberate use for the wrong version: a fast fade in and an instant reset, sometimes wanted for a tooltip. It is a choice, not the default.

The four parts of the shorthand

transition: background 160ms cubic-bezier(.2,.7,.3,1) 0ms;
/*          property   duration  timing function       delay */
Part Typical value Notes
property A named property all is convenient and animates things you did not intend
duration 120ms to 220ms Under 100ms is not perceived, over 300ms feels slow
timing function ease, ease-out, a bezier linear reads as mechanical for interface motion
delay 0ms Useful on a dropdown so a brief pointer slip does not close it

Name the properties rather than using all. all catches layout properties added later and turns a cheap effect into a per frame reflow.

Which properties are cheap

  • Cheap. opacity and transform. Handled by the compositor, no layout recalculation.
  • Moderate. background-color, color, border-color, box-shadow. A repaint each frame.
  • Expensive. width, height, padding, margin, top. Layout runs every frame for the whole subtree.

A lift on hover should be transform: translateY(-1px), not margin-top: -1px. They look identical and cost very different amounts.

The difference is invisible on one button and obvious on a table of two hundred rows, where every hover reflows the page. Check a long list before deciding a layout property is fine.

What cannot transition

Transitions need two numeric or colour endpoints the browser can interpolate.

  • display is discrete, so none to block jumps. Fade opacity and use visibility with a delay, or the newer transition-behavior: allow-discrete.
  • height: auto has no computed number until layout runs. Transitioning height to auto covers the workarounds.
  • Two different background-image values swap instantly. Stack two layers and cross fade their opacity instead.
Dev tools styles panel with the resting rule selected, the transition declaration visible on the base selector.
Dev tools styles panel with the resting rule selected, the transition declaration visible on the base selector.

Covering keyboard and touch

Hover alone reaches only one kind of reader. Two additions handle the rest.

@media (any-hover: hover) {
  .card:hover { transform: translateY(-2px); border-color: #38bdf8; }
}

.card:focus-visible {
  transform: translateY(-2px);
  border-color: #38bdf8;
  outline: 2px solid #38bdf8;
  outline-offset: 2px;
}

The any-hover query stops a touch device entering a hover state it cannot leave. :focus-visible gives keyboard users the same feedback without putting an outline on every mouse click.

Keep the outline. Removing it and relying on the colour change leaves keyboard users guessing, and a colour shift alone can fail contrast requirements.

Reduced motion

@media (prefers-reduced-motion: reduce) {
  .btn, .card { transition: none; }
}

The end state still applies, so nothing is lost. This is the same guard used for page load animation.

Different speeds in and out

An asymmetric transition often feels better than a matched one. In fast, out slower, or the reverse for something that should feel deliberate.

.chip {
  background: #1e293b;
  transition: background 220ms ease;
}
.chip:hover {
  background: #334155;
  transition-duration: 110ms;
}

The base rule still owns the property, so the return has a transition. The hover rule only overrides the duration while the pointer is there.

This is the one legitimate reason to put a transition declaration inside :hover, and it is a single sub property rather than the whole shorthand.

Transitioning several properties

List them, with their own durations if needed.

.tile {
  transition:
    transform 180ms cubic-bezier(.2,.7,.3,1),
    box-shadow 260ms ease,
    border-color 120ms linear;
}

A slightly longer shadow than movement reads as the element lifting rather than snapping. Keep the differences small. More than about a hundred milliseconds apart and the parts look unrelated.

Group hover without extra classes

Changing a child when the parent is hovered needs no JavaScript.

.card .arrow {
  opacity: 0;
  transform: translateX(-4px);
  transition: opacity 150ms ease, transform 150ms ease;
}
.card:hover .arrow { opacity: 1; transform: none; }

The transition sits on the child's resting state, which is the same rule as before applied one level down. This works well for revealing row actions in a table, where a persistent icon on every row is noise.

A table row hovered, with its action icons faded in while the other rows stay plain.
A table row hovered, with its action icons faded in while the other rows stay plain.

Hovering a whole row or card

Making the entire card clickable, with the hover effect on the card rather than the link, is a common pattern with one trap. A ::after overlay covers the text and stops it being selectable.

.card { position: relative; transition: border-color 150ms ease; }
.card:hover { border-color: #38bdf8; }
.card a::after {
  content: "";
  position: absolute;
  inset: 0;
}

Putting the overlay on the link rather than a separate element keeps one real anchor, so the URL appears in the status bar and the keyboard reaches it. Text selection inside the card still fails, which is the accepted cost.

If the card contains a second action, a delete button for example, give that button position: relative and a higher z-index so it sits above the overlay.

Checking it somewhere neutral

Hover effects that live in an external stylesheet disappear when the file moves, and the page then looks static rather than broken, so nobody reports it.

Paste the HTML into a NOS document and open the share link on a laptop and a phone. The CSS renders as written, so what you see there is what a reader sees.

Two things to confirm. That the ease works in both directions with a pointer, and that nothing stays stuck in the hover state after a tap on the phone.

Questions people ask

Why does my hover effect snap back instantly?

The transition property is inside the hover rule. It only exists while the pointer is over the element, so the return to the resting state has no transition to use. Move the line to the base selector and both directions ease.

Why does nothing transition at all?

Usually the property being changed is not animatable, for example display or background-image between two different images. Check the property, and check that the resting state has an explicit value. Transitioning from auto or from no value at all does nothing.

What happens to hover effects on a phone?

A tap can leave the hover state stuck until the reader taps elsewhere, because there is no pointer to leave. Wrap hover rules in an any-hover hover media query so touch only devices never enter that state.

Should hover and focus look the same?

Usually yes for the visual change, using focus-visible alongside hover so keyboard users get the same feedback. Keep the focus outline as well. A colour change alone is not enough to show where the keyboard is.

Keep reading