Browse by section

Web Design 日本語

Swap an Image on Hover With CSS: Fade, Slide and 5 More

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-image inside :hover
  • Chrome and Safari now cross-fade transition: background-image (checked October 2026)
  • Firefox does not blend, so stack two layers and animate opacity if 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.