Crossfade Transition
Two states stacked in the same place: one fades out while the other fades in, over 0.8 s, on a simple class toggle — no motion and no in-between color, the two images blend during the change. CSS + 0.9 KB of JavaScript, click, Enter or Space, fade shortened to 0.15 s when the user reduces motion.
Updated
This effect is free — the Transitions category contains 52 effects total, including 9 free. Effect.Labs has 840 vanilla effects. Explore the category →
Usage examples
Anna frame — honey tortoiseshell
Hand-polished Italian acetate, 5-barrel hinges. Size 50 □ 20 – 145, made in the Jura.
2-bed · 64.2 m²
Building B · 3rd floor · lot B12
South-facing balcony 7 m² · delivery Q3 2027
- Living 24.6 m²
- Bedroom 1 11.2 m²
- Bedroom 2 10.4 m²
How it works
The .crossfade-stage scene (250 px tall, #1a1a24 background, centered content) holds a .crossfade-frame of 280 × 180 px, 16 px corners, overflow: hidden. Inside, two .crossfade-state layers with position: absolute; inset: 0 fill exactly the same area: state A (indigo to violet gradient, opacity 1) and state B, placed above it in the HTML (pink to magenta gradient, opacity 0). Both share the rule transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1).
The whole change fits in two rules: .crossfade-frame.active .crossfade-state-a { opacity: 0 } and .crossfade-frame.active .crossfade-state-b { opacity: 1 }. The script adds or removes .active on click, Enter or Space on every .crossfade-frame of the page, updates aria-pressed and hides the hidden state from screen readers (aria-hidden). State B stays on screen until the next press; the second press runs the fade backwards. Measured in Chromium 151 and Firefox 153: 250 ms after the click, A is at 0.63 and B at 0.37; at 1.1 s, A is at 0 and B at 1; three seconds later, still B; the second click brings A back to 1. Same result in WebKit 26.5.
The fade is not a perfect overlay: halfway through, each layer is at about 50%, so close to a quarter of the scene background shows through (0.37 × 0.63 = 0.23 at the 250 ms reading). On two light images you see a slight dark dip mid-change; that is what makes this fade soft, and the FAQ shows how to remove it. The code depends neither on the page nor on an id: several frames coexist, each with its own state, and the script exposes nothing on window. A variant of this fade also exists in the category.
Accessibility
- Reduced motion: under
prefers-reduced-motion: reduce, the fade duration drops from 0.8 s to 0.15 s. A fade moves nothing, so it is kept, only shortened (measured: change complete within 260 ms in Chromium and Firefox). - Keyboard and screen readers: the frame has
role="button",tabindex="0"andaria-pressed; Enter and Space toggle like a click (measured in both engines). The hidden state getsaria-hidden="true": the button's announced name is the text of the visible state. A 2 px outline shows on keyboard focus (:focus-visible). - Hide nothing important in the change: for 0.8 s both texts overlap and neither is readable. Do not put information that only exists mid-fade; each state must stand on its own.
- Contrast: the white text holds on both demo gradients; with photos, add a dark scrim behind the text, especially mid-fade when both images blend.
Browser compatibility
CSS (opacity, transition, inset, :focus-visible) + 0.9 KB of JavaScript, no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26.5: toggle, state B held, return on the second click; keyboard and reduced motion measured in Chromium and Firefox. The inset property sets the floor (Chrome 87, Firefox 66, Safari 14.1); :focus-visible and the icon blur (backdrop-filter) are finishing touches that drop out without breaking the effect on older browsers.
Without JavaScript, state A stays on screen and the frame does not respond. On a browser that ignores inset, replace it with top: 0; right: 0; bottom: 0; left: 0. Without backdrop-filter, the icon keeps its 20% white background, unblurred.
The code
Copy the three blocks into your page. No dependencies.
<div class="crossfade-stage">
<div class="crossfade-frame" role="button" tabindex="0" aria-pressed="false">
<div class="crossfade-state crossfade-state-a">
<div class="crossfade-icon" aria-hidden="true">★</div>
<div>
<div style="font-size: 1rem; font-weight: 800;">State A</div>
<div style="font-size: 0.65rem; opacity: 0.7; margin-top: 2px;">Home page</div>
</div>
</div>
<div class="crossfade-state crossfade-state-b" aria-hidden="true">
<div class="crossfade-icon" aria-hidden="true">♥</div>
<div>
<div style="font-size: 1rem; font-weight: 800;">State B</div>
<div style="font-size: 0.65rem; opacity: 0.7; margin-top: 2px;">Profile page</div>
</div>
</div>
</div>
<span class="crossfade-hint">Click to toggle</span>
</div>
.crossfade-stage,
.crossfade-stage *,
.crossfade-stage *::before,
.crossfade-stage *::after {
box-sizing: border-box
}
.crossfade-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 250px;
overflow: hidden;
position: relative;
background: rgb(26, 26, 36);
color: #fff;
font-family: sans-serif
}
.crossfade-hint {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-size: .65rem;
color: rgba(255, 255, 255, .35);
text-transform: uppercase;
letter-spacing: 2px;
white-space: nowrap;
pointer-events: none
}
.crossfade-frame {
width: 280px;
height: 180px;
border-radius: 16px;
position: relative;
overflow: hidden;
cursor: pointer
}
.crossfade-frame:focus-visible {
outline: 2px solid #f9a8d4;
outline-offset: 4px
}
.crossfade-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: 1px;
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 16px
}
.crossfade-state-a {
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
opacity: 1
}
.crossfade-state-b {
background: linear-gradient(135deg, #f43f5e, #ec4899, #d946ef);
opacity: 0
}
.crossfade-frame.active .crossfade-state-a {
opacity: 0
}
.crossfade-frame.active .crossfade-state-b {
opacity: 1
}
.crossfade-icon {
width: 50px;
height: 50px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
margin-right: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
backdrop-filter: blur(10px)
}
/* Mouvement réduit : le fondu reste (aucun déplacement), raccourci à 0,15 s */
@media (prefers-reduced-motion: reduce) {
.crossfade-state {
transition-duration: .15s
}
}
(function() {
/* fx-0695 : Crossfade Transition — chaque .crossfade-frame de la page bascule entre l'état A et l'état B
(clic, Entrée ou Espace) et reste sur l'état atteint ; l'action suivante fait le chemin inverse. */
document.querySelectorAll('.crossfade-frame').forEach(function(frame) {
var a = frame.querySelector('.crossfade-state-a');
var b = frame.querySelector('.crossfade-state-b');
function toggle() {
var on = frame.classList.toggle('active');
frame.setAttribute('aria-pressed', on ? 'true' : 'false');
if (a) a.setAttribute('aria-hidden', on ? 'true' : 'false');
if (b) b.setAttribute('aria-hidden', on ? 'false' : 'true');
}
frame.addEventListener('click', toggle);
frame.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
});
})();
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Duration (CSS — transition on .crossfade-state) |
0.8 s | 0.4 s for a snappy tab change, 1.2 s for a contemplative showcase. Beyond 1.5 s, users click again during the fade. |
Curve (CSS — cubic-bezier(0.4, 0, 0.2, 1)) |
soft start, soft end | linear gives an even fade; ease-out brings B in faster at the start. |
State content (HTML — .crossfade-state-a, -b) |
icon + two lines of text on a gradient | Put any content of the same size inside: photo, plan, map. The more geometry the two contents share, the more clearly the fade shows what changes. |
Frame size (CSS — .crossfade-frame) |
280 × 180 px, 16 px corners | width: 100%; aspect-ratio: 16/9 for a full-width image; the frame keeps overflow: hidden so the rounded corners clip both states. |
Scene height (CSS — .crossfade-stage) |
250 px, #1a1a24 background |
Remove the height and background when the frame sits in your own layout; the scene only centers the demo. |
FAQ
Why is there a slight dark dip in the middle of the fade?
Both layers pass 50% at the same time: at that moment, about a quarter of the scene background shows through. If state B is opaque (a photo, a solid background), keep state A at opacity 1 and only animate B: .crossfade-frame.active .crossfade-state-a { opacity: 1 }. B, on top, covers A with a fade and no dip. Keep the crossed version when the states have transparency, otherwise A would stay visible under B.
Is this the View Transitions API?
No. It is a CSS opacity transition on two layers already in the page, which has worked everywhere for years. The View Transitions API (document.startViewTransition) produces a similar fade between two DOM states without keeping both contents around, but needs a recent browser (Chrome 111, Safari 18, Firefox 144) and a fallback elsewhere. To switch between two contents known in advance, two layers are enough.
How do I chain more than two states?
Add as many .crossfade-state layers as states, all at opacity 0, and an is-on class that sets the active one to 1. On click, remove is-on from the current state and add it to the next: the same transition does the fade. Keep aria-hidden on every state except the active one.