TransitionsFree

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.

CSSCrossfadePremium

Updated

State A
Home page
Click to toggle
Hover or click the scene to interact

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

Red run · Fri 15 Jan · 2:05 pm
LIVE
Webcam 3alt. 1,650 m
Snow110 cm · −6 °C
Red run · open1.2 km, groomed this morning, lit after dark
Night skiingTue and Fri, 5:30 – 8:30 pm: from 5:30 pm the camera shows the lit run

① Daytime webcam to night webcam — a ski resort's website

WhenOn a Friday in January, at 5:30 pm, a skier from the village opens webcam 3 to see whether night skiing is worth it: the Red run in broad daylight fades into the same run, lit for the evening session.
WhySame frame, same geometry: the fade overlays the two images and only reveals what changes, the light.
SettingsFrame enlarged to the webcam view size; both states draw the same landscape (peaks, slope, fir trees, poles and lamps at the same coordinates), only the colors change: starry sky, golden run and lamp halos in state B; time stamp inside each state.
Optical frames · Acetate 289 € Tap the photo: on its own / worn

Anna frame — honey tortoiseshell

Hand-polished Italian acetate, 5-barrel hinges. Size 50 □ 20 – 145, made in the Jura.

Book a fitting

② Frame alone to frame worn — a craft optician's product page

WhenA customer weighing up the Anna frame taps the photo: the honey tortoiseshell on white melts into the same frame worn on a face, in the same spot.
WhyA fade with no motion keeps both images in the same place: the eye compares the frame's shape on its own and on the face.
SettingsThe glasses sit in exactly the same place in both states (same .k695-glasses block), the face and the sea-green background only exist in B; nearly opaque lenses in A, clear in B so the eyes show.
LIVINGBED 1BED 2BATHHALL 7.80 m4.20 m

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²

③ Bare plan to furnished plan — a property developer's configurator

WhenA couple eyeing unit B12 turns on "Furnish" to picture themselves there: the dimensioned plan of the apartment gives way to the same plan, with sofa, beds and walkways between the furniture.
WhyThe walls do not move, only the furniture appears: the fade shows the layout without losing the landmarks.
SettingsWalls, doors, windows, room names and dimensions repeated identically in both states, furniture only in B; a "Furnish" switch next to the plan triggers the same toggle as a click on the plan and follows its state (aria-pressed).

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" and aria-pressed; Enter and Space toggle like a click (measured in both engines). The hidden state gets aria-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.

Chrome 87+✓ Full
Firefox 66+✓ Full
Safari 14.1+✓ Full
Edge 87+✓ Full
Mobile iOS 14.5+✓ Full
Android Chrome 87+✓ Full

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.

HTML
<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">&#9733;</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">&#9829;</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>
CSS
.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
  }
}
JavaScript (fx-0695)
(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 / propertyDefaultEffect
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.