CSS image hover effect, smooth fade transition tutorial

CSS image hover effect, smooth fade transition tutorial

Last verified: October 15, 2026
27 min read
Tutorial

In the jQuery era, hover fades were built with .animate() or .fadeIn(). In 2026 that is unnecessary for simple UI states: the browser interpolates opacity directly in CSS, with less script, fewer layout thrashing risks, and an effect that follows system preferences once you wire in prefers-reduced-motion.

If you searched for a CSS image hover effect, the shortest answer is this: add transition: opacity 0.3s ease-in-out; to the element, then change opacity on :hover. That pattern covers most image dimming, card thumbnails, button fades, and overlay reveals.

#CSS transition vs JavaScript for hover effects

JavaScript is the right tool when the state depends on the network, form validation or complex sequences. For “pointer over image, dim it a little”, CSS is quicker to read and cheaper to run.

Concrete advantages in practice:

  • No event listeners to clean up on SPA-like admin screens.
  • The browser can handle opacity and transform on the compositor thread once the layer exists.
  • The same rule set serves the pointer and keyboard focus when you mirror the state with :focus-visible.
  • No race conditions between mouseenter / mouseleave handlers and CSS that is already animating.

The exception is when the effect has to sync with a script-driven layer (for example a custom lightbox that sets aria-hidden). Even then, CSS can do the fade while JavaScript only toggles a class.

#Fade an image on hover with CSS opacity

Let’s say you have an image that should dim when you hover over it.

/* The Element */
.hover-image {
    opacity: 1;
    /* The Magic Part */
    transition: opacity 0.3s ease-in-out;
}

/* The Trigger */
.hover-image:hover {
    opacity: 0.7;
}

What happens:

  1. Image starts at opacity: 1 (fully visible)
  2. On hover, transitions to opacity: 0.7 (30% transparent)
  3. Takes 0.3 seconds with smooth easing
  4. Returns to opacity: 1 when hover ends

#How to change fade out effect on mouse over with CSS

The most common request is a smooth opacity change when the cursor enters or leaves an element. Here is the minimal pattern:

.element {
    opacity: 1;
    transition: opacity 0.3s ease-in-out;
}

.element:hover {
    opacity: 0.7;
}

That is the entire implementation. Three declarations on the base state, one on the hover state. The transition property tells the browser to animate the opacity change over 0.3 seconds with an ease-in-out curve.

This pattern works on images, buttons, cards, links, icons, and any other HTML element. Adjust the target opacity (0.7 in this example) to control how much the element dims. Lower values create a stronger fade effect.

For a fade-in effect on hover (element starts faded and becomes fully visible), reverse the values:

.element {
    opacity: 0.5;
    transition: opacity 0.3s ease-in-out;
}

.element:hover {
    opacity: 1;
}

#CSS animations vs transitions

#When to use animations vs transitions

Transitions:

  • Triggered by state changes (hover, focus, active)
  • Two states: from A to B
  • Simple, predictable
  • Best for: buttons, links, cards, images

Animations (@keyframes):

  • Complex multi-step sequences
  • Runs automatically or on trigger
  • Can loop, pause, reverse
  • Best for: loading spinners, complex effects, continuous motion

#Basic @keyframes animation

/* Define the animation */
@keyframes fadeInOut {
    0% {
        opacity: 0;
        transform: translateY(20px);
    }
    50% {
        opacity: 1;
        transform: translateY(0);
    }
    100% {
        opacity: 0;
        transform: translateY(-20px);
    }
}

/* Apply the animation */
.fade-animation {
    animation: fadeInOut 2s ease-in-out infinite;
}

/* Animation on hover */
.fade-on-hover:hover {
}

#Animation properties in detail

.element {
    /* Name and duration */
    animation-name: fadeIn;
    animation-duration: 0.5s;

    /* Timing function */
    animation-timing-function: ease-in-out;

    /* Delay before start */
    animation-delay: 0.2s;

    /* How many times to play */
    animation-iteration-count: infinite; /* or 1, 2, 3... */

    /* Direction */
    animation-direction: normal; /* normal, reverse, alternate, alternate-reverse */

    /* Play state (for pausing) */
    animation-play-state: running; /* or paused */

    /* Fill mode (styles before/after) */
    animation-fill-mode: forwards; /* none, forwards, backwards, both */

    /* Shorthand */
    animation: fadeIn 0.5s ease-in-out 0s 1 normal forwards;
}

/* Pause animation on hover */
.animated-element:hover {
    animation-play-state: paused;
}

#Complex hover animation example

/* Pulsing CTA button */
@keyframes pulse {
    0% {
        transform: scale(1);
        box-shadow: 0 0 0 0 rgba(0, 115, 170, 0.7);
    }
    50% {
        transform: scale(1.05);
        box-shadow: 0 0 0 15px rgba(0, 115, 170, 0);
    }
    100% {
        transform: scale(1);
        box-shadow: 0 0 0 0 rgba(0, 115, 170, 0);
    }
}

.cta-button {
    animation: pulse 2s infinite;
}

.cta-button:hover {
}

#Accessible hover effects with reduced motion

#Respecting reduced motion

/* Default: animations enabled */
.animated-element {
    transition: opacity 0.3s ease-in-out;
    animation: fadeIn 0.5s ease-in-out;
}

/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
    .animated-element {
        transition: none;
        animation: none;
    }
}

/* Alternatively: provide subtle alternative */
@media (prefers-reduced-motion: reduce) {
    .animated-element {
        transition: opacity 0.1s ease;
    }

    .complex-animation {
        animation: none;
        opacity: 1;
    }
}

#Focus states

/* Ensure hover effects also work on focus */
.interactive-element {
    opacity: 1;
    transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}

.interactive-element:hover,
.interactive-element:focus {
    opacity: 0.8;
    transform: scale(1.02);
}

/* Focus visible for keyboard navigation */
.interactive-element:focus-visible {
    outline: 2px solid #0073aa;
    outline-offset: 2px;
}
  • Use :focus-visible, not only :focus, if you want to avoid a focus ring after every mouse click in modern browsers.
  • Make sure the element can actually receive focus (<a>, <button>, or tabindex="0" with keyboard handling).
  • Do not remove outline without replacing it. A fade on its own is not enough contrast for a focus indicator.

#Hover effects on touch devices

The hover media feature from Media Queries Level 4 tests whether the user’s primary input can hover over elements. MDN notes that it reports none when the primary input cannot hover at all or cannot conveniently hover, such as phones that emulate hover with a long tap. Keep focus styles outside the media query so keyboard focus still gets the same state.

/* Remove hover effects on touch devices */
@media (hover: none) {
    .hover-only-effect:hover {
        /* No hover state */
        transform: none;
    }
}

/* Enable hover effects only on devices with hover */
@media (hover: hover) {
    .hover-only-effect {
        transition: transform 0.3s ease-in-out;
    }

    .hover-only-effect:hover {
        transform: scale(1.05);
    }
}

#CSS transition properties explained

#Transition-property

Specifies which CSS property to animate. Always specify specific properties instead of all for better performance.

/* Good: Specific property */
transition: opacity 0.3s ease-in-out;

/* Better: Multiple specific properties */

/* Avoid: Animates ALL properties (performance hit) */

Animatable Properties:

  • opacity - Fade in/out
  • transform - Scale, rotate, translate
  • background-color - Color changes
  • width, height - Size changes (use transform instead)
  • border-radius - Rounded corners
  • box-shadow - Shadow effects

Discrete (they flip instead of fading):

  • display: only transitions with transition-behavior: allow-discrete, covered in the @starting-style section below
  • font-family: instant change only

#Transition-duration

How long the animation takes. Common values:

/* Very fast (subtle) */
transition: opacity 0.15s;

/* Standard (sweet spot for UI) */

/* Slow (dramatic) */

/* Very slow (rarely used) */

Best Practices:

  • 0.2-0.3s: Standard UI interactions (buttons, links)
  • 0.15s: Micro-interactions (tooltips, badges)
  • 0.4-0.6s: Page transitions, modal animations
  • >1s: Avoid (feels sluggish)

#Transition-timing-function

Controls the acceleration curve. This is what makes animations feel “natural.”

/* Linear: Constant speed (robotic) */
transition: opacity 0.3s linear;

/* Ease: Slow start, fast middle, slow end (default) */

/* Ease-in: Slow start, fast end */

/* Ease-out: Fast start, slow end (most natural) */

/* Ease-in-out: Slow start and end, fast middle (very smooth) */

/* Custom cubic-bezier (advanced) */

When to Use Each:

  • ease-out: Most UI elements (feels responsive)
  • ease-in-out: Smooth, elegant transitions
  • ease-in: Elements appearing (less common)
  • linear: Progress bars, loading indicators
  • cubic-bezier: Custom feel (use tools like cubic-bezier.com)

#Transition-delay

A delay is useful when a child layer should wait for its parent. On a simple image fade the delay is usually 0s; anything else makes the UI feel slow.

#CSS transition default and common values

PropertyDefaultCommon Values
transition-propertyallopacity, transform, filter
transition-duration0s0.2s, 0.3s, 0.5s, 1s
transition-timing-functioneaseease, linear, ease-in, ease-out, ease-in-out
transition-delay0s0.1s, 0.2s, 0.5s

Timing functions reference:

/* Preset */
ease        /* Slow start, fast middle, slow end */
linear       /* Constant speed */
ease-in      /* Slow start, fast end */
ease-out     /* Fast start, slow end */
ease-in-out  /* Slow start and end */

/* Custom bezier */
cubic-bezier(0.4, 0.0, 0.2, 1)      /* Material Design */
cubic-bezier(0.25, 0.46, 0.45, 0.94) /* Smooth */

/* Steps */
steps(5)       /* 5 discrete steps */
steps(5, start)  /* Start immediately */
steps(5, end)    /* Wait before starting */

#CSS transition presets for hover

Subtle:

transition: all 0.2s ease-out;

Standard:

Smooth:

Bounce:

Quick:

#CSS transition shorthand syntax

transition: [property] [duration] [timing-function] [delay];

Examples:

/* Single property */

/* Multiple properties */

/* With delay */

/* Different durations per property */

#CSS hover fade with zoom, overlay and shadow

#1. Fade + scale (zoom effect)

Combine opacity and transform for a modern zoom effect:

.card-img {
    opacity: 1;
    transform: scale(1);
    transition: opacity 0.3s ease-in-out, transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.card-img:hover {
    opacity: 0.9;
    transform: scale(1.05);
}

Why this works:

  • scale(1.05) zooms image 5% larger
  • Opacity slightly reduces for depth
  • Different durations create layered effect

#2. Fade + overlay text

Perfect for image galleries:

.image-container {
    position: relative;
    overflow: hidden;
}

.image-container img {
    opacity: 1;
    transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}

.image-container .overlay {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.7);
    opacity: 0;
    transition: opacity 0.3s ease-in-out;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
}

.image-container:hover img {
    opacity: 0.7;
    transform: scale(1.1);
}

.image-container:hover .overlay {
    opacity: 1;
}

#3. Fade + color change

For buttons and links:

.button {
    background-color: #0073aa;
    color: white;
    opacity: 1;
    transition: opacity 0.2s ease-out, background-color 0.2s ease-out;
}

.button:hover {
    opacity: 0.9;
    background-color: #005177;
}

.button:active {
    opacity: 0.8;
    transform: scale(0.98);
}

#4. Fade + shadow

Add depth on hover:

.card {
    opacity: 1;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    transition: opacity 0.3s ease-in-out, box-shadow 0.3s ease-in-out, transform 0.3s ease-in-out;
}

.card:hover {
    opacity: 0.95;
    box-shadow: 0 8px 16px rgba(0,0,0,0.2);
    transform: translateY(-4px);
}

#Hover effect examples for cards, menus and galleries

#Example 1: Product card

.product-card {
    position: relative;
    overflow: hidden;
}

.product-card img {
    opacity: 1;
    transform: scale(1);
    transition: opacity 0.4s ease-in-out, transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.product-card:hover img {
    opacity: 0.8;
    transform: scale(1.1);
}

.product-card .badge {
    position: absolute;
    top: 10px;
    right: 10px;
    opacity: 0;
    transition: opacity 0.3s ease-out 0.1s;
}

.product-card:hover .badge {
    opacity: 1;
}

#Example 2: Navigation menu

.nav-link {
    border-bottom: 2px solid transparent;
    transition: opacity 0.2s ease-out, border-color 0.2s ease-out;
}

.nav-link:hover {
    opacity: 1;
    border-bottom-color: #0073aa;
}

.nav-link.active {
    opacity: 1;
    border-bottom-color: #0073aa;
}
.gallery-item {
    position: relative;
    overflow: hidden;
}

.gallery-item img {
    opacity: 1;
    filter: brightness(1);
    transition: opacity 0.3s ease-in-out, filter 0.3s ease-in-out, transform 0.4s ease-out;
}

.gallery-item:hover img {
    opacity: 0.9;
    filter: brightness(0.8);
    transform: scale(1.05);
}

.gallery-item .caption {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
    color: white;
    padding: 20px;
    opacity: 0;
    transform: translateY(20px);
    transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}

.gallery-item:hover .caption {
    opacity: 1;
    transform: translateY(0);
}

#CSS hover reveal, blur and grayscale effects

#1. Reveal hidden content

.reveal-container {
    position: relative;
    overflow: hidden;
}

.reveal-content {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.8);
    color: white;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    transform: translateY(100%);
    transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}

.reveal-container:hover .reveal-content {
    opacity: 1;
    transform: translateY(0);
}

/* Slide from bottom variant */
.slide-bottom {
}
.slide-bottom:hover {
}

/* Slide from top variant */
.slide-top {
}
.slide-top:hover {
}

/* Slide from left variant */
.slide-left {
}
.slide-left:hover {
}

/* Slide from right variant */
.slide-right {
}
.slide-right:hover {
}

#2. Blur effect

.blur-hover img {
    filter: blur(0);
    transition: filter 0.3s ease-in-out;
}

.blur-hover:hover img {
    filter: blur(5px);
}

/* Blur + scale combination */
.blur-scale {
    transition: filter 0.3s ease-in-out, transform 0.3s ease-in-out;
}

.blur-scale:hover {
    filter: blur(3px) brightness(0.8);
    transform: scale(1.05);
}

#3. Grayscale to color

.grayscale-to-color img {
    filter: grayscale(100%);
    transition: filter 0.3s ease-in-out;
}

.grayscale-to-color:hover img {
    filter: grayscale(0%);
}

/* Partial grayscale (80%) */
.partial-grayscale img {
    transition: filter 0.3s ease-in-out;
}

.partial-grayscale:hover img {
    filter: grayscale(0%);
}

#4. Tilt effect

.tilt-card {
    perspective: 1000px;
    transform-style: preserve-3d;
}

.tilt-card-inner {
    position: relative;
    transform: rotateX(0deg) rotateY(0deg);
    transition: transform 0.3s ease-out;
    transform-style: preserve-3d;
}

.tilt-card:hover .tilt-card-inner {
    transform: rotateX(5deg) rotateY(5deg);
}

/* More dramatic tilt */
.tilt-card.dramatic:hover .tilt-card-inner {
}

#5. Glitch effect

@keyframes glitch {
    0% {
        clip-path: inset(50% 0 30% 0);
        transform: translate(-5px, 0);
    }
    20% {
        clip-path: inset(20% 0 60% 0);
        transform: translate(5px, 0);
    }
    40% {
        clip-path: inset(40% 0 40% 0);
        transform: translate(-5px, 0);
    }
    60% {
        clip-path: inset(80% 0 5% 0);
        transform: translate(5px, 0);
    }
    80% {
        clip-path: inset(10% 0 70% 0);
        transform: translate(-5px, 0);
    }
    100% {
        clip-path: inset(30% 0 50% 0);
        transform: translate(5px, 0);
    }
}

.glitch-effect:hover {
    animation: glitch 0.3s linear infinite;
}

#CSS custom properties (variables)

#Using variables for reusable effects

:root {
    --transition-duration: 0.3s;
    --transition-easing: ease-in-out;
    --hover-opacity: 0.8;
    --hover-scale: 1.05;
}

.hover-effect {
    opacity: 1;
    transition: opacity var(--transition-duration) var(--transition-easing),
                transform var(--transition-duration) var(--transition-easing);
}

.hover-effect:hover {
    opacity: var(--hover-opacity);
    transform: scale(var(--hover-scale));
}

/* Dark mode variant */
@media (prefers-color-scheme: dark) {
    :root {
        --hover-opacity: 0.9;
    }
}

#Scoped variables

.card-grid {
    --card-transition: 0.3s ease-out;
    --card-hover-transform: translateY(-5px);
}

.card {
    transition: all var(--card-transition);
}

.card:hover {
    transform: var(--card-hover-transform);
}

/* Button variant */
.button-grid {
    --btn-transition: 0.2s ease;
    --btn-hover-opacity: 0.85;
}

.btn {
    transition: all var(--btn-transition);
}

.btn:hover {
    opacity: var(--btn-hover-opacity);
}

#How to add CSS hover effects in WordPress

#Loading the stylesheet with wp_enqueue_style

Avoid a loose <style> block printed from wp_head or header.php when you can. Use the theme enqueue API so dependencies, versioning and loading stay predictable.

<?php
declare(strict_types=1);

add_action('wp_enqueue_scripts', static function (): void {
    $theme_version = wp_get_theme()->get('Version');
    $css_path = get_theme_file_path('assets/css/hover-fade.css');
    $css_ver = file_exists($css_path) ? (string) filemtime($css_path) : $theme_version;

    wp_enqueue_style(
        'theme-hover-fade',
        get_theme_file_uri('assets/css/hover-fade.css'),
        [],
        $css_ver
    );
});

Why this pattern:

  • filemtime busts the cache when the file changes, without a manual version bump.
  • The theme-hover-fade handle can be dequeued or overridden in a child theme.
  • You avoid duplicates when a block or plugin tries to inject the same rules.

For block themes (FSE) you can still enqueue a small utility sheet, or put utility classes in theme.json where it fits. Larger hover rules with media queries still belong in CSS files. If the effect is only needed on one template, wrap the enqueue in is_singular() / is_page_template() to keep critical CSS smaller.

#Gutenberg block styles

/* WordPress block editor styles */
.wp-block-image .hover-effect img {
    transition: transform 0.3s ease-in-out, filter 0.3s ease-in-out;
}

.wp-block-image .hover-effect:hover img {
    transform: scale(1.05);
    filter: brightness(0.9);
}

/* Cover block hover */
.wp-block-cover .hover-cover {
    transition: transform 0.4s ease-in-out;
}

.wp-block-cover:hover .hover-cover {
    transform: scale(1.02);
}

#Elementor widgets

/* Elementor image box */
.elementor-image-box-img {
    transition: transform 0.3s ease-in-out, filter 0.3s ease-in-out;
}

.elementor-image-box:hover .elementor-image-box-img {
    transform: scale(1.05);
    filter: brightness(1.1);
}

/* Elementor button */
.elementor-button {
    transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}

.elementor-button:hover {
    transform: translateY(-3px);
    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}

#Hover transitions in Tailwind and Bootstrap

#Tailwind CSS

<!-- Tailwind hover utilities -->
<img class="transition-all duration-300 ease-in-out hover:opacity-80 hover:scale-105"
     src="image.jpg" alt="Hover effect with Tailwind">

<!-- Custom transition -->
<button class="transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-lg">
    Hover me
</button>

#Bootstrap

/* Bootstrap custom hover */
.custom-hover {
    transition: all 0.3s ease-in-out;
}

.custom-hover:hover {
    transform: translateY(-5px);
    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
}

/* Override Bootstrap transitions */
.btn-custom {
    transition: all 0.2s ease-out;
}

#Custom CSS Grid with hover

.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: 20px;
}

.card-grid .card {
    transition: all 0.3s ease-in-out;
}

.card-grid .card:hover {
    transform: translateY(-8px);
    box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
}

/* Staggered animation */
.card-grid .card:nth-child(1) { transition-delay: 0ms; }
.card-grid .card:nth-child(2) { transition-delay: 50ms; }
.card-grid .card:nth-child(3) { transition-delay: 100ms; }
.card-grid .card:nth-child(4) { transition-delay: 150ms; }

#Why a fade does not work in Gutenberg blocks

Gutenberg changes the DOM structure more than classic templates do. A fade that “worked in Classic” usually fails for one of these reasons.

#Wrong selector on the wrapper

Blocks are often wrapped in .wp-block-image, .wp-block-group or figure/figcaption. A rule that targets img:hover directly misses when the hover happens on the link wrapped around the image.

/* Too narrow in many block layouts */
.wp-block-image img:hover {
  opacity: 0.7;
}

/* More robust */
.wp-block-image a:hover img,
.wp-block-image a:focus-visible img,
.wp-block-image figure:hover img {
  opacity: 0.7;
}

#Inline styles and content images

Image styles set in the editor (style="width:...") do not necessarily compete with opacity, but an !important in a theme reset or plugin CSS can. Keep specificity low and consistent, and avoid !important for a fade.

#Additional CSS versus a block stylesheet

“Additional CSS” under Appearance and Customize is fine for prototypes. For production, use a dedicated file via enqueue, or a block-specific style registered with wp_enqueue_block_style, so the rules load only where the block is used.

#Interactivity and nested blocks

Group blocks with links inside can trigger hover on the parent while focus sits on a child. Test with both the mouse and Tab. :focus-within on the card wrapper solves many of these cases.

#Editor versus front end

The editor canvas does not always load the theme’s front-end CSS. If a client “does not see the fade in the editor”, that is expected unless you also load the styles through enqueue_block_assets or add_editor_style. Document the split: the effect shows on the live site, not necessarily in the canvas.

#Which CSS properties are cheap to animate

#Browser rendering pipeline and animation cost

Browser rendering pipeline:

  1. Style - Calculate CSS rules
  2. Layout - Calculate element positions (expensive!)
  3. Paint - Draw pixels (expensive!)
  4. Composite - Layer merging (GPU accelerated)

Properties by performance cost:

PropertyCostNotes
opacityLowComposite only
transformLowGPU accelerated
filterMediumCan be GPU accelerated
background-colorMediumRepaint required
width/heightHighLayout recalculation
top/leftHighLayout recalculation
box-shadowHighRepaint required

#Using CSS containment

.animated-container {
    contain: layout paint;
    /* Isolates the element for better performance */
}

.animated-child {
    will-change: transform, opacity;
    transition: transform 0.3s, opacity 0.3s;
}

#Hover transitions and Core Web Vitals (CLS, INP)

CLS (Cumulative Layout Shift):

/* Reserve space to prevent layout shift */
.animated-element {
    transform: translateZ(0); /* Creates a new compositing layer */
    backface-visibility: hidden; /* Better performance */
}

INP (Interaction to Next Paint):

/* Quick transitions for responsiveness */
.responsive-element {
    transition: transform 0.15s ease-out; /* Fast response */
}

#How to make CSS hover animations smooth

#Use transform instead of position and size

Bad (triggers layout recalculation):

.element {
    left: 0;
    transition: left 0.3s;
}
.element:hover {
    left: 100px;
}

Good (GPU accelerated):

.element {
    transform: translateX(0);
    transition: transform 0.3s;
}
.element:hover {
    transform: translateX(100px);
}

#Limit animated properties

Bad:

transition: all 0.3s; /* Animates everything */

Good:

#Use will-change for complex animations

.animated-element {
    will-change: transform, opacity;
    transition: transform 0.3s, opacity 0.3s;
}

Note: Only use will-change on elements that will actually animate. Remove it when animation completes.

#Why a CSS fade does not show

  1. Check that the start and end values actually differ.
  2. Check that transition sits on the base rule, not only on :hover. The transition is read from the style the element is moving to, so without it the return transition is missing.
  3. Look for other rules that set transition: none globally.
  4. Check prefers-reduced-motion in the operating system.
  5. In WordPress, confirm the stylesheet is enqueued (View Source or the Network panel) and that the selector matches the rendered block DOM.

#Common CSS hover transition mistakes

#Mistake 1: Using all

/* Bad: Animates everything, performance hit */
transition: all 0.3s;

/* Good: Specific properties */

#Mistake 2: Too long duration

/* Bad: Feels sluggish */

/* Good: Snappy and responsive */

#Mistake 3: Animating layout properties

/* Bad: Triggers layout recalculation */

/* Good: Use transform instead */

#Mistake 4: Missing hover state

/* Bad: No hover state defined */
.element {
    transition: opacity 0.3s;
}

/* Good: Define both states */
.element {
    opacity: 1;
    transition: opacity 0.3s;
}
.element:hover {
    opacity: 0.7;
}

#How to debug CSS transitions in DevTools

#Chrome DevTools

  1. Open DevTools (F12)
  2. Select element with transition
  3. Check “Animations” tab
  4. See timeline and properties
  5. Adjust timing in real-time

#Firefox DevTools

  1. Open DevTools (F12)
  2. Select element
  3. Check “Animations” panel
  4. Visualize animation curve

#How to spot which elements are animating

/* Add this temporarily to see what's animating */
* {
    outline: 1px solid red !important;
}

Then check which elements are repainting (red flash = performance issue).

#Which transition rule applies when hover starts and ends

CSS Transitions Module Level 1 is still a W3C Working Draft, last published on 8 January 2026, not a finished Recommendation. The spec says the duration, delay and timing function come from the element’s after-change style, the style the element is moving to. When the pointer enters the image, the transition on the :hover rule applies. When the pointer leaves, the transition on the base rule applies.

Two practical consequences follow:

  • If transition sits only on :hover, the image fades down but snaps back when the pointer leaves, because the base rule has no transition.
  • You can give the way in and the way out different speeds on purpose, for example a quick response on entry and a calmer return.
.hover-image {
  opacity: 1;
  transition: opacity 0.3s ease-out; /* used on the way back */
}

.hover-image:hover {
  opacity: 0.7;
  transition-duration: 0.15s; /* used on the way in */
}

The spec also covers the pointer leaving before the fade has finished. Section 3.1, “Faster reversing of interrupted transitions”, explains that a return taking the full duration over a shorter distance looks lopsided. So the reverse transition is shortened by a dedicated factor (the reversing shortening factor), and the whole timing function is still used, so the return does not jump into the middle of the curve. No JavaScript is needed to work out the remaining time. Source: CSS Transitions Module Level 1.

#Does an opacity fade count as motion under WCAG

WCAG 2.2 success criterion 2.3.3 Animation from Interactions (level AAA) requires that motion animation triggered by interaction can be disabled, unless the animation is essential to the functionality or the information being conveyed. The W3C Understanding document for 2.3.3 draws the line: changes of color or opacity that do not change the perceived size, shape or position of the element are not motion animation. An erratum removed blurring from that exception, so filter: blur() is no longer covered by it.

A pure opacity fade therefore sits outside 2.3.3, while transform: scale() changes perceived size and falls inside it. That allows a more precise reduced-motion rule than switching everything off:

@media (prefers-reduced-motion: reduce) {
  .card-img {
    transform: none;
    transition: opacity 0.2s ease-out;
  }
}

MDN describes the same move in its prefers-reduced-motion reference: a scaling animation is toned down to a dissolve animation, which is not a vestibular motion trigger. The reduce keyword evaluates as true, so @media (prefers-reduced-motion) is equivalent to @media (prefers-reduced-motion: reduce).

Focus falls under a different criterion. 2.4.7 Focus Visible (level AA) requires a visible focus indicator, and MDN also points to 1.4.11 Non-text Contrast, which asks for at least 3:1 contrast for that indicator. An opacity change on an image rarely meets that on its own, so focus needs its own outline or box-shadow, as in the focus states example above.

#Why opacity 0 does not hide an element

The MDN reference for opacity flags three things hover effects often miss:

  • An element at opacity: 0 is invisible but still part of the DOM. It still registers pointer events and, if it is in the tab order, it gets focus. A hidden overlay button can be clicked and focused. Make it visible when it receives focus, or switch off pointer events with pointer-events and take it out of the tab order.
  • Opacity alone does not hide content from screen readers. Use the hidden attribute, visibility or display for that.
  • Any value other than 1 creates a new stacking context. An overlay with a z-index inside a card that fades to 0.9 can end up in a different stacking order than expected.

If only the background should dim, MDN recommends a background color with an alpha channel instead of opacity on the whole element. With text over an image, the contrast between text and background still has to hold after the fade.

#Fade in on first render with @starting-style

A transition only starts when a value changes. MDN explains that transitions are not triggered on an element’s initial style update, or when display changes from none to a visible value. A card inserted into the DOM at opacity: 1 therefore does not fade in on its own.

@starting-style defines the values the transition should start from. For elements that toggle to and from display: none, you also need transition-behavior: allow-discrete, because display has a discrete animation type. When transitioning to none, the value flips at 100% of the duration, so the element stays visible for the whole fade.

.notice {
  opacity: 1;
  transition: opacity 0.3s ease-out;
  transition: opacity 0.3s ease-out, display 0.3s allow-discrete;
}

.notice.is-hidden {
  display: none;
  opacity: 0;
}

@starting-style {
  .notice {
    opacity: 0;
  }
}

The first transition line is deliberate: MDN writes the property twice so that browsers without allow-discrete support still animate opacity. @starting-style has the same specificity as the original rule, so it has to come after it. MDN marks both features as Baseline 2024. Older browsers show the element without a fade, which is an acceptable fallback for a purely visual transition.

#How to react to a finished fade with transitionend

When JavaScript only toggles a class and CSS does the fade, the script can listen for transitionend to know when the transition is done, for example before setting hidden or moving focus. The MDN page on transitionend and the spec give four rules for when the event actually arrives:

  • transitionend fires in both directions: when the element reaches the hover state and when it is fully back.
  • If the transition is removed before completion, for example because transition-property is removed or display is set to none, no transitionend fires.
  • If transitioncancel fires, for example because the pointer leaves halfway through the fade, no transitionend fires for that transition.
  • If both duration and delay are 0s, there is no transition and none of the transition events fire.

The last rule is the one that breaks in practice. When prefers-reduced-motion sets transition: none, a script that waits for transitionend to clean up waits forever. Filter on propertyName, and keep a path forward when no transition runs.

const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

// In CSS: .is-fading-out { opacity: 0; }
function hideNotice(el) {
  el.classList.add('is-fading-out');
  if (reduce) {
    el.hidden = true;
    return;
  }
  el.addEventListener('transitionend', function onEnd(event) {
    if (event.propertyName !== 'opacity') return;
    el.removeEventListener('transitionend', onEnd);
    el.hidden = true;
  });
}

#Browser support for transition, focus-visible and prefers-reduced-motion

The table lists the first version with unprefixed support, taken from MDN browser-compat-data version 8.1.6 (October 2026).

FeatureChromeFirefoxSafariEdge
transition2616912
:focus-within605210.179
:focus-visible868515.486
prefers-reduced-motion746310.179
@media (hover)3864912
transition-behavior11712917.4117
@starting-style11712917.5117

Internet Explorer supports transition from version 10, but none of the other features in the table. Safari shipped transition with the -webkit- prefix from version 3.1, and Opera has supported it unprefixed since 12.1. A browser without transition support simply shows the end state with no animation:

/* Modern browsers */
.element {
    opacity: 1;
    transition: opacity 0.3s ease-in-out;
}

.element:hover {
    opacity: 0.7;
}

/* Fallback: Instant change (no animation) */
/* Older browsers will just show the end state */

One consequence of the table is easy to miss. In a browser without :focus-visible, such as Safari before 15.4, the whole rule .card-media:hover, .card-media:focus-visible is dropped, hover part included. MDN’s article on selector lists explains that a single unsupported selector invalidates the entire list. Write hover and focus as two separate rules, or wrap them in :is(), which takes a forgiving selector list.

#Summary

CSS transitions are the modern, performant way to create hover effects. They’re:

  • Fast: opacity and transform can run on the compositor, with no JavaScript
  • Simple: one transition declaration on the base rule
  • Accessible: once you add :focus-visible and a prefers-reduced-motion rule
  • Maintainable: easy to modify and debug

Key Takeaways:

  • Use specific properties, not all
  • 0.2-0.3s is the sweet spot for UI interactions
  • ease-out feels most natural
  • Use transform instead of position/size properties
  • Combine multiple properties for rich effects
  • Test on real devices for performance
  • Use @starting-style when an element should fade in on first render

In 2026, CSS transitions are the standard for hover effects. JavaScript animations are only needed for complex, interactive animations that CSS can’t handle.

Explore our professional WordPress development to take your project further.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Frequently asked questions

Practical answers to apply the topic in real execution.

How do you create a smooth CSS hover transition?#
Use the CSS transition property with a specific animatable property such as opacity or transform, a short duration like 0.2s to 0.3s, and an ease or ease-in-out timing function.
How do you fade an image on hover with CSS?#
Set the default state to opacity 1, then change it on :hover, for example to 0.7, and add transition: opacity 0.3s ease-in-out for a smooth fade.
Why should you use CSS instead of JavaScript for hover effects?#
CSS hover effects are lighter, easier to maintain and usually smoother because browsers can optimise transitions without extra JavaScript work.
How do you change the fade out effect on mouse over with CSS?#
Add transition: opacity 0.3s ease-in-out to the element, then set a lower opacity value on the :hover pseudo-class. The browser handles the smooth fade animation automatically without JavaScript.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles