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
opacityandtransformon 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/mouseleavehandlers 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:
- Image starts at
opacity: 1(fully visible) - On hover, transitions to
opacity: 0.7(30% transparent) - Takes 0.3 seconds with smooth easing
- Returns to
opacity: 1when 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>, ortabindex="0"with keyboard handling). - Do not remove
outlinewithout 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/outtransform- Scale, rotate, translatebackground-color- Color changeswidth,height- Size changes (use transform instead)border-radius- Rounded cornersbox-shadow- Shadow effects
Discrete (they flip instead of fading):
display: only transitions withtransition-behavior: allow-discrete, covered in the@starting-stylesection belowfont-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 transitionsease-in: Elements appearing (less common)linear: Progress bars, loading indicatorscubic-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
| Property | Default | Common Values |
|---|---|---|
transition-property | all | opacity, transform, filter |
transition-duration | 0s | 0.2s, 0.3s, 0.5s, 1s |
transition-timing-function | ease | ease, linear, ease-in, ease-out, ease-in-out |
transition-delay | 0s | 0.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;
}Example 3: Image gallery
.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:
filemtimebusts the cache when the file changes, without a manual version bump.- The
theme-hover-fadehandle 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:
- Style - Calculate CSS rules
- Layout - Calculate element positions (expensive!)
- Paint - Draw pixels (expensive!)
- Composite - Layer merging (GPU accelerated)
Properties by performance cost:
| Property | Cost | Notes |
|---|---|---|
opacity | Low | Composite only |
transform | Low | GPU accelerated |
filter | Medium | Can be GPU accelerated |
background-color | Medium | Repaint required |
width/height | High | Layout recalculation |
top/left | High | Layout recalculation |
box-shadow | High | Repaint 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
- Check that the start and end values actually differ.
- Check that
transitionsits 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. - Look for other rules that set
transition: noneglobally. - Check
prefers-reduced-motionin the operating system. - 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
- Open DevTools (F12)
- Select element with transition
- Check “Animations” tab
- See timeline and properties
- Adjust timing in real-time
Firefox DevTools
- Open DevTools (F12)
- Select element
- Check “Animations” panel
- 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
transitionsits 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: 0is 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 withpointer-eventsand take it out of the tab order. - Opacity alone does not hide content from screen readers. Use the
hiddenattribute,visibilityordisplayfor that. - Any value other than
1creates a new stacking context. An overlay with az-indexinside a card that fades to0.9can 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:
transitionendfires 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-propertyis removed ordisplayis set tonone, notransitionendfires. - If
transitioncancelfires, for example because the pointer leaves halfway through the fade, notransitionendfires 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).
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
transition | 26 | 16 | 9 | 12 |
:focus-within | 60 | 52 | 10.1 | 79 |
:focus-visible | 86 | 85 | 15.4 | 86 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
@media (hover) | 38 | 64 | 9 | 12 |
transition-behavior | 117 | 129 | 17.4 | 117 |
@starting-style | 117 | 129 | 17.5 | 117 |
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:
opacityandtransformcan run on the compositor, with no JavaScript - Simple: one
transitiondeclaration on the base rule - Accessible: once you add
:focus-visibleand aprefers-reduced-motionrule - 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-outfeels most natural- Use
transforminstead of position/size properties - Combine multiple properties for rich effects
- Test on real devices for performance
- Use
@starting-stylewhen 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.







