Modern CSS Image Hover Effects with Accessibility
Pure CSS hover effects for images with a modern flavor, built with accessibility and performance in mind. Demo and code included.
Image hover effects are not as complicated as they may seem. That said, making them attractive and good-looking takes a little bit more effort.
With some simple design tweaks and CSS basics, let’s create some crisp and modern CSS hover effects built specifically for images.
What you’ll learn:
- Basic CSS hover Zoom
- Zoom and pan
- 3d tilting images
- Grayscale to color
Hover transition basics
CSS transition, transformation, and filter properties are the heart of these image hover effects. Here are some pointers to implement such effects correctly:
- Use the older CSS
transformproperty only for complex operations. For simple stuff, use the modern, standalone transformation properties likescale,translate, etc. for better application and flexibility. - Employ a CSS reset if you haven’t already, for consistent rendering of elements across browsers.
- Manage all the customizable values with CSS custom properties. This approach keeps things organized and flexible.
Check out this working example demonstrating the basic CSS hover zooming, where scaling and transition bits are managed using CSS variables instead of hard coding values:
Basic CSS zooming in and out
Preview-onlyAdvanced image hover zoom
We just established zooming an image in and out on hover. However, it’s still not polished enough to achieve what you see on modern Web apps.
Let’s break the technique down into small actionable steps and cover them one by one to understand them better.
Planning the structure
What’s wrong with a standalone image? Nothing at all, as far as a simple transition/animation is concerned.
However, there are cases when you’d like such an image to operate within certain limits or pair with other elements like captions, labels, and other contents.
This calls for wrapping everything inside a container and layering them in such a way that they appear in harmony both in terms of arrangement and animations.
<div class="card">
<!-- The image -->
<img
src="path/to/image"
class="card__img" />
<!-- Optional card's contents -->
<div class="card__content">
<!-- Stuff like captions, labels, etc. -->
</div>
</div>
Better image handling
Adding width and height attributes to <img> is crucial for avoiding CLS issues. Also make sure to add other important attributes including alt and loading for better SEO and UX practices.
Container layout
The image card should have some shape and size. Let’s use aspect-ratio to size it up. You may add rounded corners using border-radius too.
.card {
aspect-ratio: 4 / 3;
}
Tweaking alignment
The idea is to make the image fill up its parent horizontally and vertically, regardless of the size of the parent.
The right way to do so is using 100% width and height to stretch the image across its parent’s boundaries, and then using object-fit property to keep this stretching proportionate:
.card__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
Managing the overflow
In our current setup, the scaling, translating, or any movement on the image will push it beyond its parent’s boundaries.
This will result in an ugly, unfinished effect. To tackle that, here’s a little overflow tweak:
.card {
overflow: hidden;
}
Adding Transitions
Just define all the settings in the image container, and set up the required properties for the image including the transitions:
Basic CSS zooming in and out
Preview-onlyAdding focus-within and active states
On bigger screens, we expect features like these to follow the focused and active states of the associated elements. Let’s explore some quick recipes of these effects where we also use :active and :focus-within.
Quick zoom-in
Let’s speed up the simple zoom on hover effect we accomplished above, trimming the amount of scaling just a little bit.
.card--zoom-in {
--img-scale: 1;
--img-scale-transition-duration: 150ms;
--img-scale-transition-function: ease-in-out;
}
/* Quick-zoom Container */
.card--zoom-in .card__img {
scale: var(--img-scale);
transition:
scale
var(--img-scale-transition-duration)
var(--img-scale-transition-function);
}
/* The Transformation */
.card--default:hover .card__img,
.card--default:focus-within .card__img,
.card--default:active .card__img { {
--img-scale: 1.1;
}
This effect offers a quick, subtle zoom-in that looks good on thumbnails in listings and card grids.
Snappy hover zoom
/* Snap — near-instant, for dense dashboards / thumbnails */
.card--snap .card__img {
transform-origin: 0 0;
--img-transition-duration: 160ms;
--img-transition-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.card--snap:hover .card__img,
.card--snap:focus-within .card__img,
.card--snap:active .card__img {
scale: 1.08;
}
3d Tilt
/* Tilt — soft 3D pickup, like lifting a photo off a table */
.card--tilt {
--img-perspective: 900px;
}
.card--tilt .card__img {
scale: 1.04;
--img-transition-duration: 500ms;
--img-transition-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.card--tilt:hover .card__img,
.card--tilt:focus-within .card__img,
.card--tilt:active .card__img {
scale: 1.1;
rotate: 0 1 0.15 6deg;
}
Rotate pop
/* Rotate Pop — playful spring, slight overshoot */
.card--rotate .card__img {
--img-transition-duration: 550ms;
--img-transition-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.card--rotate:hover .card__img,
.card--rotate:focus-within .card__img,
.card--rotate:active .card__img {
scale: 1.08;
rotate: 2.5deg;
}
Cinematic
/* Cinematic — slow, patient, luxurious */
.card--cinematic .card__img {
transform-origin: 50% 60%;
--img-transition-duration: 1100ms;
--img-transition-function: cubic-bezier(0.19, 1, 0.22, 1);
}
.card--cinematic:hover .card__img,
.card--cinematic:focus-within .card__img,
.card--cinematic:active .card__img {
scale: 1.12;
}
Diagonal pan drift
/* Drift — diagonal pan, reveals a corner of the frame */
.card--drift .card__img {
scale: 1.12;
transform-origin: 80% 80%;
--img-transition-duration: 750ms;
--img-transition-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card--drift:hover .card__img,
.card--drift:focus-within .card__img,
.card--drift:active .card__img {
scale: 1.2;
translate: -6% -6%;
}
Push
/* Push — inverts the pattern: image recedes on hover */
.card--push .card__img {
scale: 1.1;
--img-transition-duration: 500ms;
}
.card--push:hover .card__img,
.card--push:focus-within .card__img,
.card--push:active .card__img {
scale: 1;
}
Colorize
/* Colorize — grayscale to color */
.card--colorize .card__img {
filter: grayscale(100%) saturate(80%);
--img-transition-duration: 800ms;
}
.card--colorize:hover .card__img,
.card--colorize:focus-within .card__img,
.card--colorize:active .card__img {
scale: 1.05;
filter: grayscale(0%) saturate(110%);
}
Focus pull
/* Focus Pull — blur to sharp */
.card--unblur .card__img {
scale: 1.03;
filter: blur(3px) brightness(90%);
--img-transition-duration: 500ms;
}
.card--unblur:hover .card__img,
.card--unblur:focus-within .card__img,
.card--unblur:active .card__img {
scale: 1.1;
filter: blur(0) brightness(100%);
}
Spotlight
/* Spotlight — dim to bright */
.card--brighten .card__img {
filter: brightness(75%) contrast(95%);
--img-transition-duration: 500ms;
}
.card--brighten:hover .card__img,
.card--brighten:focus-within .card__img,
.card--brighten:active .card__img {
scale: 1.06;
filter: brightness(100%) contrast(100%);
}
Accessibility
The general accessibility concern that we have with effects like this is not optimizing them for reduced motion settings. If the end-user is using reduced motion features in their browser, these effects will keep showing the movement we have provided them.
To tackle that, it’s recommended to reduce or zero down the transitions and animations using the prefers-reduced-motion media block. Here’s an example:
@media (prefers-reduced-motion: reduce) {
.card__img {
transition: none !important;
animation: none !important;
scale: 1 !important;
translate: 0 !important;
rotate: none !important;
filter: none !important;
}
}
/* Disable hover effects on touch devices where hover is emulated */
@media (hover: none) {
.card__img {
transition: none !important;
animation: none !important;
scale: 1 !important;
translate: 0 !important;
rotate: none !important;
filter: none !important;
}
}
/* Prevent touch-action conflicts on mobile */
.card {
touch-action: manipulation;
}
Performance considerations
Let’s say you have hundreds of such effects on a page. Now, this may make your page lag and heavy to browse, spoiling the user experience.
Pre-rendering performance
To tackle that, use the will-change property on the image to tell the browser to allocate resources for the effect in advance, which reduces the lagging.
.card--zoom-in .card__img {
will-change: scale;
}
Defining the rendering boundary
There’s this rarely-used CSS property that talks to the browser directly and helps it cut down additional re-rendering work.
In our case, the image doesn’t have to span outside its container, therefore we can use contain: paint to tell the browser to not re-render it beyond that boundary.
.card {
contain: paint;
}
More effects
Further, you may play with the transform and transition properties to create more attractive effects. I’ll share more of such slick effects in the future as well, covering more elements and not just images.
Wrapping up, I hope this came in handy for you in some way or other. If it did, let me know through your comments. Grab all the code covered here in this GitHub repo, and keep supporting the blog for more content like this. Cheers!
Written by Rahul C.
Founder and curator of W3Bits. Have questions, suggestions, or anything to say about this post? Reach out on X or send me an email.