Swap an Image on Hover With CSS (and How to Fade It)
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.