This article covers how to swap an image on hover with CSS alone, no JavaScript involved.
The short answer: the swap itself is one :hover rule that changes background-image. If you want it to fade, stack two layers and animate opacity.
I have corrected this article. It used to say that background-image cannot be transitioned because the property animates discretely. When I measured it again in October 2026, Chrome and Safari cross-faded it quite happily. Firefox still does not interpolate. The measurements are below.
▼What you want, and what to reach for
| Goal | Approach |
|---|---|
| Swap instantly | Change background-image on :hover |
| Fade, the same way in every browser | Stack two layers and transition opacity |
| Slide, zoom, flip and so on | Stack two layers and move transform or clip-path |
| Cut down requests | A sprite plus background-position |
Swap an <img> element |
src cannot be changed, so place both and stack them |
| Serve different resolutions | image-set() or <picture> |
Sponsored
The basic swap
The short answer: prepare two images, set the first as a background, and replace it inside :hover.
The image goes in as a background rather than an <img> because CSS cannot touch 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 too. Leave them out and the crop shifts in ways you did not plan for as soon as the image’s aspect ratio differs from the element’s. The same reasoning applies to content images, which I went through in using object-fit to fill the width and crop without distortion.
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 very first hover can show nothing for a moment.
I recorded the network traffic in Chrome to confirm it. Written this way, the hover image is fetched the instant the pointer first lands on the element. With the stacked approach further down, both files are fetched while the page is still loading.
background-image transitions now work in Chrome and Safari
The short answer: as of October 2026, Chrome and Safari cross-fade a change of background-image. Firefox does not interpolate it.
This article previously said that background-image has a discrete animation type and therefore cannot be transitioned. As a description of how browsers behave today, that is wrong, and I am correcting it.
I ran a one-second transition between two images and recorded the computed value frame by frame.
- What I tested on: Chrome 154, Firefox 157 and WebKit 27.2 on macOS 26.5.1, plus Safari on an iPhone 16 Pro Max simulator running iOS 18.2. Checked on 9 October 2026
- “Safari” here means Safari on iOS. I have not enabled automation for Safari on macOS, so I have not captured that build myself
▼What transition: background-image 1s actually does
| Browser | Computed value mid-transition | What you see |
|---|---|---|
| Chrome 154 | A cross-fade(...) whose ratio climbs |
Cross-fades |
| Safari (iOS 18.2) | A cross-fade(...) whose ratio climbs |
Cross-fades |
| Firefox 157 | No change, straight to the end value | Swaps in one frame |
In Safari I stretched the transition to six seconds and took a screenshot partway through: both images were visibly on screen at once, showing through each other. They really are being blended.
/* cross-fades in Chrome and Safari; snaps in Firefox */
.image-container {
background-image: url('normal-image-url');
transition: background-image 0.3s ease;
}
.image-container:hover {
background-image: url('hover-image-url');
}
allow-discrete does not blend them in Firefox
Adding transition-behavior: allow-discrete in Firefox does make the transition “run”. It does not blend anything, though: the image flips over in one frame, at exactly the halfway mark of the duration you set.
/* in Firefox this flips at 0.5s with no fade */
.image-container {
transition: background-image 1s;
transition-behavior: allow-discrete;
}
For the first half-second you see the original image, and from then on you see the new one. If you want an actual fade, you are still stacking two layers.
Sponsored
Three reasons stacking is still the default
The short answer: even now that Chrome and Safari cross-fade, I still build these by stacking.
- Firefox does not fade. If the effect has to look the same everywhere, stacking is the only way
- The hover image is fetched up front. The first hover never flashes empty
- You get more than a fade. Sliding, zooming and flipping are impossible with a cross-fade
Stacking two layers and fading with opacity
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 top, right, bottom and left to zero at once, which makes the pseudo-element sit exactly over its parent.
Respect reduced-motion preferences while you are here.
@media (prefers-reduced-motion: reduce) {
.image-container::after {
transition: none;
}
}
Seven ways to reveal the second image
The short answer: once two layers are stacked, every other effect is the same idea with a different property. Swap opacity for transform or filter and you have a new one.
I rendered all seven in Chrome 154, Firefox 157 and WebKit 27.2 and checked the screenshots, so these are not sketches.
1. Fade
The opacity version above. It is the safest of the seven and never looks wrong, whatever the two images are.
2. Slide
The hover image slides in from the left. overflow: hidden is mandatory. I removed it to see what happens, and the waiting image was visible off to the left of the element even before hovering.
.slide {
position: relative;
overflow: hidden;
width: 200px;
height: 150px;
background: url('normal-image-url') center / cover;
}
.slide::after {
content: "";
position: absolute;
inset: 0;
background: url('hover-image-url') center / cover;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.slide:hover::after {
transform: translateX(0);
}
3. Zoom
One image only: hovering scales the image inside its frame. Forget overflow: hidden on the parent and the enlarged image spills out past the element. I checked that one by removing it too.
.zoom {
overflow: hidden;
width: 200px;
height: 150px;
}
.zoom img {
display: block;
width: 100%;
height: 150px;
object-fit: cover;
transition: transform 0.3s ease;
}
.zoom:hover img {
transform: scale(1.15);
}
4. Flip, like a card
Put one image on the front face and one on the back, then rotate 180 degrees around the Y axis. backface-visibility: hidden tells the browser not to paint a face that is turned away.
<div class="flip">
<div class="flip-inner">
<div class="face front"></div>
<div class="face back"></div>
</div>
</div>
.flip {
width: 200px;
height: 150px;
perspective: 600px;
}
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.5s;
transform-style: preserve-3d;
}
.flip:hover .flip-inner {
transform: rotateY(180deg);
}
.face {
position: absolute;
inset: 0;
backface-visibility: hidden;
background-size: cover;
background-position: center;
}
.front { background-image: url('normal-image-url'); }
.back { background-image: url('hover-image-url'); transform: rotateY(180deg); }
In Chrome 154 I saw no difference with backface-visibility: visible, either during the rotation or at the end of it. That leaves the paint order of the two faces up to the browser, though, so I keep hidden in there to state the intent.
5. Restore the colour from greyscale
One image again. Show it desaturated and bring the colour back on hover. This is the one that suits a grid of client logos.
.gray img {
display: block;
width: 100%;
height: 150px;
object-fit: cover;
filter: grayscale(1);
transition: filter 0.3s ease;
}
.gray:hover img {
filter: grayscale(0);
}
6. Darken it and bring up a label
The image never changes; a translucent black panel and some text come in over it. For a card that is a link, this is the kindest of the seven, because it tells you where you are about to go.
<a class="overlay" href="/example/"><span>View details</span></a>
.overlay {
position: relative;
display: grid;
place-items: center;
width: 200px;
height: 150px;
background: url('normal-image-url') center / cover;
}
.overlay::before {
content: "";
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
transition: opacity 0.3s;
}
.overlay span {
position: relative; /* keep the label above the dark panel */
color: #fff;
font-weight: 700;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.3s, transform 0.3s;
}
.overlay:hover::before,
.overlay:hover span {
opacity: 1;
}
.overlay:hover span {
transform: translateY(0);
}
7. Open it as a circle (clip-path)
The hover image grows out of the centre as a circle. clip-path can be transitioned, so the shape itself does the reveal.
.circle {
position: relative;
width: 200px;
height: 150px;
background: url('normal-image-url') center / cover;
}
.circle::after {
content: "";
position: absolute;
inset: 0;
background: url('hover-image-url') center / cover;
clip-path: circle(0% at 50% 50%);
transition: clip-path 0.4s ease;
}
.circle:hover::after {
clip-path: circle(75% at 50% 50%);
}
Sponsored
Using a sprite image
The short answer: put both images in one file and shift background-position. Half the requests, and the swap is instantaneous.
.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 has always been animatable, so a transition works here without any of the caveats above.
.image-container {
transition: background-position 0.3s ease;
}
Because it is a position that moves, though, it reads as a slide rather than a fade. That is a different effect, so choose it deliberately rather than by accident.
One more thing: since HTTP/2, fetching several files in parallel is much cheaper than it used to be. Cutting the request count is a weaker argument for sprites than it once was, so weigh it against the extra maintenance of keeping one combined file up to date.
Swapping an img element
The short answer: 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 for decorative reasons, so there is no sense in a screen reader announcing it a second time.
▼Background image or img element
| Background image | <img> |
|
|---|---|---|
| Role of the image | Decoration | Content |
| Alternative text | Not possible | Via alt |
| Search engines | Rarely picked up as an image | Indexed |
| Best for | Decorative icons, backdrops | Product photos, diagrams |
Anything that carries meaning — a product photo, say — belongs in an <img>. As a background it gets no alt and will not turn up in image search.
Where picture and image-set() fit in
The short answer: neither one is a way to swap images on hover. They exist to serve the same image differently depending on the device, so you layer them on top of the stacking approach.
picture cannot change its src either, so place two
Which <source> a <picture> resolves to is decided by the browser while the page loads, and hovering cannot make it reconsider. Place two complete <picture> elements and stack them.
<div class="pic-swap">
<picture>
<source srcset="normal.webp" type="image/webp">
<img src="normal.jpg" alt="Product exterior">
</picture>
<picture class="top">
<source srcset="hover.webp" type="image/webp">
<img src="hover.jpg" alt="" aria-hidden="true">
</picture>
</div>
.pic-swap {
position: relative;
width: 200px;
height: 150px;
}
.pic-swap img {
display: block;
width: 200px;
height: 150px;
object-fit: cover;
}
.pic-swap .top {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.3s;
}
.pic-swap:hover .top {
opacity: 1;
}
Whether WebP or AVIF is worth the extra <source> depends on your own images. I measured file size against matched quality in JPEG vs WebP vs AVIF at matched image quality rather than trusting the usual percentage claims.
For backgrounds, resolution switching means image-set()
Backgrounds have no srcset, so image-set() takes its place. CSS.supports() returned true in Chrome 154, Firefox 157, WebKit 27.2 and Safari on iOS 18.2, and I confirmed the rendering in Chrome, Firefox and WebKit.
.image-container {
background-image: image-set(
url('normal@1x.png') 1x,
url('normal@2x.png') 2x
);
background-size: cover;
}
.image-container::after {
content: "";
position: absolute;
inset: 0;
background-image: image-set(
url('hover@1x.png') 1x,
url('hover@2x.png') 2x
);
background-size: cover;
opacity: 0;
transition: opacity 0.3s;
}
.image-container:hover::after {
opacity: 1;
}
To catch older engines as well, write -webkit-image-set() and a plain url() above it, in that order.
.image-container {
background-image: url('normal@1x.png'); /* for engines that parse neither */
background-image: -webkit-image-set(url('normal@1x.png') 1x, url('normal@2x.png') 2x);
background-image: image-set(url('normal@1x.png') 1x, url('normal@2x.png') 2x);
}
Devices that cannot hover
The short answer: phones have no hover state. Treat anything that is only visible on hover as unavailable to those visitors.
I queried both on Safari under iOS 18.2: (hover: hover) and (pointer: fine) were false. The contents of the media query below therefore never apply on an iPhone in the first place.
/* only where the pointer can hover accurately */
@media (hover: hover) and (pointer: fine) {
.image-container:hover::after {
opacity: 1;
}
}
Scoping it this way also avoids the sticky hover you get after tapping, where the hover state stays applied until you tap something else. For a decorative swap, this is all you need.
The problem case is when the second image carries information. Then do not hide it behind hover at all — show it from the start on touch devices.
/* where hovering is impossible, reveal the second image up front */
@media (hover: none) {
.image-container::after {
opacity: 1;
}
}
If both images need to be seen, drop the swap entirely and place them side by side, or move the second one behind a tap. The same show-and-hide reasoning turns up in building an accordion menu and building a jQuery dropdown menu, both of which cover CSS-only alternatives.
Summary
- To swap and nothing more, change
background-imageinside:hover - Chrome and Safari now cross-fade
transition: background-image(checked October 2026) - Firefox does not blend, so stack two layers and animate
opacityif consistency matters - Sliding, zooming, flipping and clip-path reveals are all variations on the stacked layout
- Wrap hover effects in
@media (hover: hover)for devices that cannot hover
If you are relearning CSS properly rather than looking up one effect, four books for learning web coding sets out an order to work through.