
How to Change Image on Hover: CSS Image Hover Effects That Don't Hurt Performance
Change image on hover with pure CSS, zoom images smoothly, and add css image hover effects without layout shift. Includes preload rules for the hover state, touch-device fallbacks, and accessibility parity.
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 — 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.
<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>
.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
srcattributes immediately. The hover state is ready before the pointer arrives. - No layout shift. The container sizes from the first image’s
widthandheightattributes. The second image is absolutely positioned and never affects layout. - Accessible. The first image carries the
alttext. The hover image gets an emptyaltbecause it duplicates the same content — screen readers should hear the description once. - Keyboard friendly. The
:focus-visiblerule 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. A WebP hover image typically decodes 30 percent faster than the equivalent JPEG at the same quality, and hover effects punish slow decodes with a visible fade-in lag. Our 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:
- Add
loading="lazy"to the hover image only. Browsers fetch it when it nears the viewport, which usually happens well before a user hovers. - 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:
.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
:hoverrule are not fetched until the first hover. The first hover shows a blank or half-painted box while the image downloads. - No
alttext. 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", nofetchpriority, nosrcset. 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.
<figure class="zoom-frame">
<img src="/img/landscape.webp"
alt="Coastal cliffs at sunset"
width="800" height="533"
loading="lazy">
</figure>
.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 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
transformandopacity. Never animatewidth,height,top,left, ormargin.
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.
/* 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 handle that in the browser, and our Core Web Vitals guide 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.
<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>
.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%)totranslateY(0). - Slide from left:
translateX(-100%)to0. - Fade:
opacity: 0to1. - 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 (as above) guarantees contrast. 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.
<figure class="fig-reveal">
<img src="/img/chart-traffic.webp"
alt="Organic traffic after image optimization"
width="720" height="480">
<figcaption>
Organic traffic 90 days after switching to WebP. Typical result, not a guarantee.
</figcaption>
</figure>
.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.
/* 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:
<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 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:
/* 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:
- 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. - 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
:hoverrule gets a:focus-visible(or:focus-within) twin, so tab users see the same effect:
.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="". Twoalttexts 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:
@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.
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 });
}
<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. Swappingsrcbefore that shows the old image or a blank box mid-load. The decode-then-swap order removes the flash entirely.- Swap on
focusintoo, so keyboard parity holds. - Keep the base image in the HTML with real
alttext. The script enhances; it never replaces the accessible default. - For grids, attach one
pointerenterlistener 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 ships hover zoom, spin, and gallery behavior with the preloading handled for you, and 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 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.