# How to Change Image on Hover: CSS Image Hover Effects That Don't Hurt Performance

> CSS image hover effects without layout shift: zoom, fade and overlay, with preload rules, touch fallbacks and accessibility parity.

Canonical page: https://www.imageguide.dev/guides/image-hover-effects/

Published: 2026-08-21. Updated: 2026-10-17.

Publisher: ImageGuide, published by Sirv. [Editorial method](https://www.imageguide.dev/about/#how-we-write).

## Quick answer

Build image hover effects with stable dimensions and CSS opacity or transform changes. Provide the same meaningful action for keyboard and touch users, and respect reduced-motion preferences. Keep important information available without hover, and avoid downloading a large alternate image before anyone needs it.

Reference for this answer: [W3C image accessibility guidance](https://www.w3.org/WAI/tutorials/images/).

Hover effects make a page feel alive. You move the pointer over a card and the image swaps, zooms, or brightens. Done well, this costs almost nothing: a few lines of CSS, no JavaScript, no layout shift. Done badly, it flashes an empty box, shifts the layout, or does nothing at all on a phone.

This guide covers every common technique to change image on hover, how to zoom an image on hover with transforms, and the performance and accessibility rules that separate a polished effect from a janky one. If you want the product-page-specific zoom that online stores use, that lives in our [e-commerce product images guide](/guides/ecommerce-product-images/) — this article owns the general techniques.

## The Techniques at a Glance

| # | Technique | JS needed? | Best for | Main risk |
|---|-----------|------------|----------|-----------|
| 1 | Two stacked images, opacity toggle | No | Image swaps | Hover image not preloaded |
| 2 | Background-image swap | No | Legacy codebases | No SEO text, no preload, worse a11y |
| 3 | `transform: scale()` + `overflow: hidden` | No | Image zoom on hover | Blurry upscale beyond 1.2x |
| 4 | CSS filters (grayscale, brightness) | No | Mood, focus, galleries | Contrast loss on photos |
| 5 | Overlay caption slide-in | No | Cards, portfolios | Text over busy images |
| 6 | `:has()` state tricks | No | Parent-driven effects | Older Safari support |
| 7 | `figure` / `figcaption` reveal | No | Semantic content | Same as overlays |
| 8 | JS swap with `decode()` | Yes | Precise control, analytics | Extra complexity |

Rules 1 through 7 are pure CSS. Start there. Add JavaScript only when CSS genuinely cannot do the job.

## Change Image on Hover with Two Stacked Images

The cleanest way to swap an image on hover uses two `<img>` elements inside a container. The second image sits on top with `opacity: 0`. On hover, its opacity becomes 1. No JavaScript, no `src` mutation, and the browser can preload both images from the HTML alone.

```html
<a href="/product/blue-sneaker" class="img-swap">
  <img src="/img/sneaker-front.webp" alt="Blue sneaker, front view" width="600" height="450">
  <img src="/img/sneaker-side.webp" alt="" class="img-swap-alt" width="600" height="450" loading="lazy">
</a>
```

```css
.img-swap {
  position: relative;
  display: block;
}

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

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

.img-swap:hover .img-swap-alt,
.img-swap:focus-visible .img-swap-alt {
  opacity: 1;
}
```

Why this approach wins:

- **Both images load from HTML.** The browser's preload scanner sees both `src` attributes immediately. The hover state is ready before the pointer arrives.
- **No layout shift.** The container sizes from the first image's `width` and `height` attributes. The second image is absolutely positioned and never affects layout.
- **Accessible.** The first image carries the `alt` text. The hover image gets an empty `alt` because it duplicates the same content — screen readers should hear the description once.
- **Keyboard friendly.** The `:focus-visible` rule mirrors the hover state for keyboard users. More on that in the accessibility section.

Two details matter. First, keep both images the same pixel dimensions. If the hover image is a different aspect ratio, `height: auto` on the base image and `inset: 0` on the overlay will stretch it. Second, use a fast format. Compare decoding and loading on your target devices; a format alone does not establish a speed advantage. Our [complete WebP guide](/guides/complete-webp-guide/) covers the encoding settings.

### When the hover image is heavy

The stacked approach loads both images up front. For a grid of 40 product cards, that doubles the image payload. Two fixes:

1. Add `loading="lazy"` to the hover image only. Browsers fetch it when it nears the viewport, which usually happens well before a user hovers.
2. For grids where lazy loading is not enough, fall back to the JavaScript approach near the end of this guide, which fetches the hover image on `pointerenter`.

## The Background-Image Swap and Why to Avoid It

The older pattern swaps a `background-image` on hover:

```css
.card {
  background-image: url("/img/front.webp");
  background-size: cover;
}

.card:hover {
  background-image: url("/img/back.webp");
}
```

It works, but it has real downsides:

- **The browser does not preload the hover image.** Background images referenced only in a `:hover` rule are not fetched until the first hover. The first hover shows a blank or half-painted box while the image downloads.
- **No `alt` text.** Background images are invisible to screen readers and to image search.
- **No intrinsic sizing.** The container needs explicit dimensions, so you maintain them by hand and risk layout shift on small screens.
- **No `loading="lazy"`, no `fetchpriority`, no `srcset`.** You lose every responsive-image tool.

Use this pattern only in legacy code you cannot touch. For new work, the stacked `<img>` approach gives the same visual result with none of these costs. If you must keep background images for the base state, at least preload the hover image with a `link` tag — the next section shows how.

## Image Zoom on Hover with Transforms

The most popular css image hover effect is the zoom. The pattern: wrap the image in a container with `overflow: hidden`, then scale the image on hover. The container clips the growth, so nothing around it moves.

```html
<figure class="zoom-frame">
  <img src="/img/landscape.webp"
       alt="Coastal cliffs at sunset"
       width="800" height="533"
       loading="lazy">
</figure>
```

```css
.zoom-frame {
  overflow: hidden;
  margin: 0;
}

.zoom-frame img {
  display: block;
  width: 100%;
  height: auto;
  transition: transform 0.4s ease;
  will-change: transform;
}

.zoom-frame:hover img,
.zoom-frame:focus-within img {
  transform: scale(1.08);
}
```

A scale of 1.05 to 1.1 reads as a smooth zoom. Go past about 1.2 and two problems appear: the zoom looks aggressive, and the browser upscales pixels beyond their native resolution, which softens the image. If you need a deeper zoom for product detail, ship a larger source image and see the [e-commerce product images guide](/guides/ecommerce-product-images/) for the full product-zoom pattern.

### Why transform and not width or height

Animating `width`, `height`, or `padding` triggers layout on every frame. The browser recalculates positions for the image and everything near it, then repaints. On a busy page this drops frames visibly.

`transform: scale()` and `opacity` are different. The browser can run both on the compositor thread, off the main thread, with no layout and no repaint of surrounding content. This is why the performance rule for hover effects is short:

> Animate only `transform` and `opacity`. Never animate `width`, `height`, `top`, `left`, or `margin`.

The `will-change: transform` line promotes the image to its own compositing layer so the first hover frame is smooth. Use it sparingly — one per animated image, not on hundreds of grid items, because each promoted layer costs memory. On large grids, drop it and let the browser promote on first hover.

### Zoom from a point

By default the image scales from its center. To zoom toward the pointer position you need JavaScript to set `transform-origin`, which is one of the few cases where a small script earns its place. The product-zoom guide covers that variant in depth.

## CSS Filters: Grayscale to Color and Beyond

Filters change how an image renders without touching the file. They are cheap, they animate smoothly on the compositor, and they pair well with the zoom.

```css
/* Grayscale photo that gains color on hover */
.filter-fade img {
  filter: grayscale(100%);
  transition: filter 0.4s ease, transform 0.4s ease;
}

.filter-fade:hover img {
  filter: grayscale(0%);
  transform: scale(1.03);
}

/* Dimmed gallery thumbnail that brightens */
.thumb img {
  filter: brightness(0.75) saturate(0.9);
  transition: filter 0.3s ease;
}

.thumb:hover img {
  filter: brightness(1) saturate(1);
}
```

Common hover filters:

| Filter | Hover use | Watch out for |
|--------|-----------|---------------|
| `grayscale()` | Color-on-hover reveal | Color is the reward — make sure the gray state still shows the subject |
| `brightness()` | Lift dark thumbnails | Over 1.2 washes out skin tones |
| `saturate()` | Muted grid, vivid hover | Below 0.5 looks broken rather than intentional |
| `blur()` | Focus-the-hovered-item grids | Blurred neighbors still load at full size — no bandwidth saving |
| `sepia()` | Vintage card sets | Rarely fits modern brands |

Two performance notes. Filters run on the compositor, so they animate smoothly, but heavy `blur()` values on large images cost GPU memory. And filters do not change the file the user downloads — a grayscale thumbnail still ships its full color data. For genuinely smaller thumbnails, compress the assets themselves; the [image compression tools](/tools/compress/) handle that in the browser, and our [Core Web Vitals guide](/guides/core-web-vitals-images/) explains how image weight feeds into LCP.

## Overlay Captions and Slide-Ins

Cards often reveal a caption, a price, or a call to action on hover. The reliable pattern puts the caption in a positioned overlay and animates its `transform` or `opacity` — again, compositor-friendly properties only.

```html
<a href="/gallery/alps" class="card">
  <img src="/img/alps.webp" alt="Alpine lake at dawn" width="640" height="420" loading="lazy">
  <div class="card-overlay">
    <h3>Alpine Lakes</h3>
    <p>12 new shots from the 2026 trip</p>
  </div>
</a>
```

```css
.card {
  position: relative;
  display: block;
  overflow: hidden;
}

.card img {
  display: block;
  width: 100%;
  height: auto;
}

.card-overlay {
  position: absolute;
  inset: auto 0 0 0;
  padding: 1rem;
  color: #fff;
  background: linear-gradient(to top, rgb(0 0 0 / 0.75), transparent);
  transform: translateY(100%);
  transition: transform 0.35s ease;
}

.card:hover .card-overlay,
.card:focus-visible .card-overlay {
  transform: translateY(0);
}
```

Variants, all with the same skeleton:

- **Slide up** (above): `translateY(100%)` to `translateY(0)`.
- **Slide from left**: `translateX(-100%)` to `0`.
- **Fade**: `opacity: 0` to `1`.
- **Image shift**: move the image itself, `translateY(-8px)`, while the caption fades in below.

Because the overlay sits inside the link, the whole card is one focusable target, and the `:focus-visible` rule reveals the caption for keyboard users too.

One content rule: keep overlay text short and put it over the darkest part of the image. A gradient scrim can improve contrast; measure the actual foreground and background combination. If the text carries information the user needs before hovering — a product name, a price — it should be visible by default. Hover reveals are for enrichment, never for hiding required content.

## Figure and Figcaption Reveals

When the revealed text is genuinely part of the content — a photo credit, a chart caption — use the semantic pair `figure` and `figcaption` instead of a styled `div`. Screen readers announce the association, and search engines can parse it.

```html
<figure class="fig-reveal">
  <img src="/img/chart-traffic.webp"
       alt="Organic traffic after image optimization"
       width="720" height="480">
  <figcaption>
    Illustrative chart; these values do not establish a traffic effect from switching format.
  </figcaption>
</figure>
```

```css
.fig-reveal {
  position: relative;
  margin: 0;
  overflow: hidden;
}

.fig-reveal figcaption {
  position: absolute;
  inset: auto 0 0 0;
  padding: 0.75rem 1rem;
  background: rgb(0 0 0 / 0.7);
  color: #fff;
  font-size: 0.875rem;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.fig-reveal:hover figcaption,
.fig-reveal:focus-within figcaption {
  opacity: 1;
  transform: translateY(0);
}
```

The difference from the overlay pattern is semantic, not visual. Use `figcaption` when the text describes the image; use a plain overlay when the text is UI, such as "View project".

## Modern Tricks with :has()

The `:has()` selector lets a parent react to its children's state. All current browsers support it. It unlocks hover effects that previously needed JavaScript.

```css
/* Highlight the card under the pointer AND dim its siblings */
.grid:has(.card:hover) .card:not(:hover) {
  opacity: 0.55;
  filter: saturate(0.7);
  transition: opacity 0.3s ease, filter 0.3s ease;
}

/* Swap the image inside a card when a sibling button is hovered */
.card:has(.btn-color:hover) .img-swap-alt {
  opacity: 1;
}

/* Zoom only when the card contains a link (not plain images) */
.tile:has(a:hover) img {
  transform: scale(1.06);
}
```

The first example is the classic "focus the hovered item" gallery: hovering any card dims the rest. Before `:has()`, that required JS to toggle classes on every sibling.

Support is broad — Chrome, Edge, Firefox, and Safari all ship it. For older Safari versions, treat the effect as progressive enhancement: the page works without it, the effect simply does not run. That is the right posture for every effect in this guide.

## Performance Rules for Hover Effects

Hover effects sit on top of the image pipeline. Get the pipeline wrong and the effect itself becomes the performance bug. Four rules cover almost everything.

### 1. Preload the hover-state image or it flashes empty

This is the number one hover bug. If the hover image loads only on hover — via a `:hover` background-image or a JS `src` swap — the first hover shows a blank or half-loaded image. Users see a flash of nothing.

The stacked-image technique avoids it because both `src` attributes sit in the HTML. When you cannot do that, preload the hover image in the document head:

```html
<link rel="preload" as="image" href="/img/sneaker-side.webp" fetchpriority="low">
```

`fetchpriority="low"` matters. It tells the browser to fetch the hover image without competing with the images users actually see first. Preload only the hover images near the top of the page — a preload for every card in a long grid wastes bandwidth on images many users never hover. For grids, prefer `loading="lazy"` on the hover image, which fetches it on approach instead of immediately.

### 2. Use same-size assets to avoid layout shift

Both states of a swap must have identical pixel dimensions and aspect ratio. If the hover image is taller, the container resizes when it appears, and everything below it jumps. That is layout shift — a CLS penalty — triggered by a pointer move.

Fix it at the asset level: export both states at the same size. Then lock the container with explicit `width` and `height` attributes on the base image so the browser reserves the exact box before either image loads. Our [responsive images guide](/guides/responsive-images-complete-guide/) covers how intrinsic sizing prevents shift across breakpoints.

### 3. Animate transform and opacity only

Repeated because it decides smoothness: `transform` and `opacity` composite off the main thread. `width`, `height`, `top`, `left`, `margin`, and `padding` trigger layout per frame. A 60 fps hover zoom on transform can drop to the low tens of fps when animated through `height`.

### 4. Keep hover-state files small

The hover image is bonus weight — it serves one interaction. Serve it in a modern format, compress it slightly harder than the base image, and match its resolution to its rendered size, not to the base image's source resolution. A hover image at 1x rendered size is usually fine; the zoom effect scales the same pixels, so a 1.08 zoom needs roughly 8 percent more resolution than the rendered box, which the source image already has.

## Touch Devices: No Hover

On touch screens there is no pointer to hover. Tapping an element with a `:hover` rule applies the hover state, but the behavior is inconsistent: on iOS the first tap often applies hover and requires a second tap to follow the link. Users experience this as a broken link.

Design for touch explicitly:

```css
/* Pointer-precise devices (mouse, trackpad): full hover effects */
@media (hover: hover) and (pointer: fine) {
  .card:hover .card-overlay {
    transform: translateY(0);
  }

  .zoom-frame:hover img {
    transform: scale(1.08);
  }
}

/* Touch devices: reveal content by default, skip the hover dance */
@media (hover: none) {
  .card-overlay {
    transform: none;
    opacity: 1;
  }
}
```

Two viable strategies for touch:

1. **Always-visible fallback.** Show the caption or the second image state by default under `@media (hover: none)`. Nothing is hidden, nothing depends on a gesture.
2. **Tap-to-toggle.** A small JS handler toggles a class on tap, with the link following on a second tap or a separate button. This costs code and confuses some users — use it only when both states are essential.

The `@media (hover: hover) and (pointer: fine)` guard is the modern default. Wrap every hover effect in it and touch devices get a clean, static experience automatically.

## Accessibility: Never Convey Information by Hover Alone

Hover is a pointer-only channel. Keyboard users cannot hover. Screen reader users may not receive hover state at all. So the accessibility rules:

- **If information appears on hover, it must be available without hover.** Captions should exist in the DOM (they do, in every pattern above), and critical content must be visible by default. Hover reveals enrichment; they never gate required content.
- **Mirror hover with focus.** Every `:hover` rule gets a `:focus-visible` (or `:focus-within`) twin, so tab users see the same effect:

```css
.card:hover .card-overlay,
.card:focus-visible .card-overlay {
  transform: translateY(0);
}
```

- **Describe only once.** In the two-image swap, the hover image gets `alt=""`. Two `alt` texts for the same subject make screen readers say it twice.
- **Respect reduced motion.** Users who set "reduce motion" in their OS ask for fewer animations:

```css
@media (prefers-reduced-motion: reduce) {
  .zoom-frame img,
  .card-overlay,
  .fig-reveal figcaption {
    transition: none;
  }

  .zoom-frame:hover img {
    transform: none;
  }
}
```

A cross-fade or a short zoom is usually acceptable even with reduced motion, but instant state changes are always safe. When in doubt, cut the transition, keep the state change.

- **Keep contrast.** Overlay text over images needs a scrim or gradient behind it. Check the dimmed state of filter effects too — a `brightness(0.75)` thumbnail with dark text on top can fail contrast.

## When CSS Is Not Enough: JS Enhancement

CSS covers most hover needs. JavaScript earns its place in three cases: preloading on approach, swapping `src` for very large grids, and pointer-tracking zooms. The pattern below handles the first two — it fetches the hover image when the pointer approaches, then swaps it only after it has fully decoded, so the user never sees a half-painted frame.

```js
function enhanceHoverSwap(card) {
  const base = card.querySelector("img");
  const hoverSrc = card.dataset.hoverSrc;
  if (!hoverSrc) return;

  let swapped = false;

  const swap = () => {
    if (swapped) return;
    swapped = true;
    const img = new Image();
    img.src = hoverSrc;
    // decode() resolves when the image is fully decoded and ready to paint
    img.decode().then(() => {
      base.src = hoverSrc;
    }).catch(() => {
      // decode failed; swap anyway so the interaction still works
      base.src = hoverSrc;
    });
  };

  card.addEventListener("pointerenter", swap, { once: false });
  // Keyboard users: swap on focus too
  card.addEventListener("focusin", swap, { once: false });
}
```

```html
<a href="/product/tee" class="js-swap" data-hover-src="/img/tee-back.webp">
  <img src="/img/tee-front.webp" alt="T-shirt, front view" width="600" height="750">
</a>
```

Key points:

- **`img.decode()`** waits until the image is decoded and ready to display. Swapping `src` before that shows the old image or a blank box mid-load. The decode-then-swap order removes the flash entirely.
- **Swap on `focusin` too**, so keyboard parity holds.
- **Keep the base image in the HTML** with real `alt` text. The script enhances; it never replaces the accessible default.
- **For grids**, attach one `pointerenter` listener per card lazily, or use a single delegated listener on the grid container. Do not attach hundreds of listeners up front.

If you run a visual platform, some of this comes built in. Sirv's [media viewer](https://sirv.com/help/articles/sirv-media-viewer/) ships hover zoom, spin, and gallery behavior with the preloading handled for you, and [Sirv Studio](https://www.sirv.studio/) can prepare the hover-state assets themselves — background removal, AI edits, and alt text generation. It is the fastest route when you would rather not maintain the JS yourself; you can [create an account](https://my.sirv.com/#/signup) and serve the hover states from their CDN.

## Choosing a Technique

| Situation | Use |
|-----------|-----|
| Product or portfolio card, alternate view on hover | Two stacked images, opacity toggle |
| Simple zoom emphasis on any image | `transform: scale()` + `overflow: hidden` |
| Gallery where the hovered item pops | `:has()` sibling dimming |
| Photo credit or caption tied to the image | `figure` / `figcaption` reveal |
| CTA or price reveal on a card | Overlay slide-in with scrim |
| Mood shift across a grid | Filters (grayscale, brightness) |
| Huge grid, heavy hover images | JS swap with `decode()` + approach preloading |

Whatever you pick, the same four checks apply: the hover state preloads or loads on approach, both states share dimensions, only `transform` and `opacity` animate, and the effect works without a pointer. Nail those and your hover effects will feel instant on every device.

## Sources

- [Using CSS transitions, MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions)
- [prefers-reduced-motion, MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
