This article covers how to swap an image on hover using CSS alone, with no JavaScript.

The short answer: swapping the image is just background-image inside :hover. But background-image cannot be transitioned, so a fade requires stacking two layers and animating opacity.

▼Choosing an approach

Goal Approach
Swap instantly Change background-image on :hover
Fade between them Stack two layers, animate opacity
Cut down requests A sprite plus background-position
Swap an <img> element Stack both images and fade

The basic swap

Prepare two images, set one as the background, and replace it on :hover.

The image goes in as a background rather than an <img> because CSS cannot change an src attribute. For a CSS-only solution, backgrounds are the way in.

The base state

.image-container {
  width: 200px;
  height: 150px;
  background-image: url('normal-image-url');
  background-size: cover;
  background-position: center;
}

Set background-size and background-position as well. Without them, the crop shifts unpredictably when the image’s aspect ratio differs from the element’s.

The hover state

.image-container:hover {
  background-image: url('hover-image-url');
}

That works, but the hover image only starts downloading when the pointer arrives, so the first hover can show nothing for a moment. The stacked approach below loads both up front and solves that at the same time.

Why transition does not work on background-image

transition: background-image has no effect. The property has a discrete animation type, meaning there are no intermediate states to interpolate.

This changes the image instantly, with no animation:

/* this does nothing */
.image-container {
  transition: background-image 0.3s ease;
}

Colour (background-color) and position (background-position) can both be animated. Swapping the image itself cannot. To fade, stack two layers and animate their opacity.

Stacking two layers and fading

Put the hover image on a pseudo-element and transition its opacity.

<div class="image-container"></div>
.image-container {
  position: relative;
  width: 200px;
  height: 150px;
  background-image: url('normal-image-url');
  background-size: cover;
}

.image-container::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: url('hover-image-url');
  background-size: cover;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.image-container:hover::after {
  opacity: 1;
}

inset: 0 is shorthand for setting all four offsets to zero, which makes the pseudo-element cover its parent exactly.

Both images are fetched when the page loads with this approach, so the first hover is not delayed.

Respect reduced-motion preferences as well:

@media (prefers-reduced-motion: reduce) {
  .image-container::after {
    transition: none;
  }
}

Using a sprite image

Combine both images into one file and shift background-position. That halves the number of requests and the swap is instant.

.image-container {
  width: 200px;
  height: 150px;
  background-image: url('sprite-image-url');
  background-position: 0 0; /* the normal image */
}

.image-container:hover {
  background-position: 0 -150px; /* the hover image */
}

background-position can be animated, so a transition does work here.

.image-container {
  transition: background-position 0.3s ease;
}

The result reads as a slide rather than a fade, which is a different effect — worth deciding deliberately.

Note that since HTTP/2, parallel requests are far cheaper than they were. Reducing request count alone is a weaker argument for sprites than it used to be, so weigh it against the extra maintenance.

Swapping an img element

CSS cannot change an src. If the image is content rather than decoration, place both and fade between them.

<div class="img-swap">
  <img src="normal.jpg" alt="Product exterior">
  <img src="hover.jpg" alt="" aria-hidden="true">
</div>
.img-swap {
  position: relative;
  display: inline-block;
}

.img-swap img {
  display: block;
  width: 100%;
}

.img-swap img:last-child {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.3s ease;
}

.img-swap:hover img:last-child {
  opacity: 1;
}

Give the second image an empty alt and aria-hidden="true". It shows the same subject, so there is no reason for a screen reader to announce it twice.

▼Background image or img element

Background image <img>
Role of the image Decoration Content
Alternative text Not possible Via alt
Search engines Rarely indexed Indexed
Best for Decorative icons, backdrops Product photos, diagrams

Anything meaningful — a product photo, for instance — belongs in an <img>. As a background it gets no alt and will not appear in image search.

Touch devices

Phones have no hover state. Assume anything only visible on hover is unavailable on touch.

Some devices produce a temporary hover on tap, but the behaviour varies by browser. If the swapped image carries information, do not put it behind hover alone.

For purely decorative swaps, scope the rule to devices that can hover:

@media (hover: hover) and (pointer: fine) {
  .image-container:hover {
    background-image: url('hover-image-url');
  }
}

Summary

The thing to remember is that background-image cannot be transitioned. For a fade, always stack two layers and animate opacity.

The same show-and-hide reasoning turns up in building an accordion menu and building a jQuery dropdown menu, both of which also cover CSS-only alternatives.

ABOUT ME
りん
On this blog, I mainly share information about web development and programming, along with my daily thoughts and what I’ve learned. I aim to create a blog that lets readers enjoy both technology and everyday life, so I also include topics about daily experiences, books, and gourmet. I’d be delighted if you could drop by casually and find something useful or enjoyable here.