---
name: page-transition-fade
description: Use when you want "Clean, professional, subtle" - Fades pages in and out during navigation.
---

# Page Transition Fade

> **Category:** Loading / Transition  -  **Personality:** Clean, professional, subtle
>
> **Best use:** Most websites and apps
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 1/5 - Visual impact 2/5 - Difficulty 1/5 - Performance cost 1/5

## What it is

Page Transition Fade swaps between views by **cross-fading their opacity** (with a small vertical drift) instead of doing a hard cut or a full browser reload. The outgoing view fades out and lifts away while the incoming one fades up into place, so moving around the site feels continuous and calm. In a real single-page app you trigger it on route change; the demo applies the exact same technique to 3-4 in-DOM "pages" with no reload at all. It's the most universally appropriate transition — quiet enough for any professional site, cheap enough to never cost performance.

## Dependencies / CDN

None — pure CSS transition plus a few lines of vanilla JS to toggle a class. The demo loads two Google Fonts (optional, for the studio look):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

Stack every "page" in one container; the active one carries `.is-active`, and each nav control points at a view via `data-target`.

```html
<nav>
  <button type="button" data-target="home" aria-current="page">Home</button>
  <button type="button" data-target="work" aria-current="false">Work</button>
  <button type="button" data-target="studio" aria-current="false">Studio</button>
</nav>

<div class="pf-viewport">
  <section class="pf-view is-active" data-view="home"  aria-hidden="false">…</section>
  <section class="pf-view"           data-view="work"   aria-hidden="true">…</section>
  <section class="pf-view"           data-view="studio" aria-hidden="true">…</section>
</div>
```

## CSS

```css
/* Stack all views in one grid cell so they overlap and the container
   stays the height of the tallest view — content cross-fades, no jump. */
.pf-viewport{display:grid;align-items:start}
.pf-view{grid-area:1 / 1}

/* The transition itself: fade + a small vertical drift. */
.pf-view{
  opacity:0; visibility:hidden; transform:translateY(18px);
  transition:opacity .55s cubic-bezier(.33,0,.2,1),
             transform .55s cubic-bezier(.33,0,.2,1),
             visibility .55s linear;
}
.pf-view.is-active {opacity:1; visibility:visible; transform:none}            /* shown  */
.pf-view.is-leaving{opacity:0; visibility:hidden; transform:translateY(-18px)} /* exits upward */

/* Motion-sensitive users get an instant swap, no drift. */
@media (prefers-reduced-motion:reduce){
  .pf-view{transition-property:opacity,visibility; transform:none}
  .pf-view.is-leaving{transform:none}
}
```

Transitioning `visibility` (not `display`) keeps the outgoing view visible while it fades, then drops it from layout and the a11y tree once the fade ends.

## JavaScript

```js
var current='home', busy=false, DUR=580;   /* DUR ≳ the CSS .55s */
function view(n){ return document.querySelector('.pf-view[data-view="'+n+'"]'); }

function go(name){
  if(busy || name===current) return;        /* lock out clicks mid-fade */
  busy=true;
  var from=view(current), to=view(name);

  from.classList.remove('is-active');
  from.classList.add('is-leaving');          /* fade out + lift away   */
  to.classList.remove('is-leaving');
  void to.offsetWidth;                       /* reflow → enter animates */
  to.classList.add('is-active');             /* fade in from below     */
  current=name;

  setTimeout(function(){                      /* reset spent view, unlock */
    from.classList.remove('is-leaving');
    busy=false;
  }, DUR);
}

document.querySelectorAll('[data-target]').forEach(function(el){
  el.addEventListener('click', function(){ go(el.getAttribute('data-target')); });
});
```

## How it works

- **Every view lives in the DOM at once**, stacked in a single CSS-grid cell (`grid-area:1/1`). Only `opacity` / `visibility` / `transform` change — never `display` — so the browser can animate between states.
- Clicking a link removes `.is-active` from the current view and adds `.is-leaving` (opacity → 0, drift up) while the target gains `.is-active` (opacity → 1, drift to rest). The two transitions run at the same time, which reads as a **cross-fade**.
- A forced reflow (`void to.offsetWidth`) between the class changes guarantees the incoming view starts from its hidden idle state, so the enter transition actually plays even if that view was just on screen a moment ago.
- A `busy` lock ignores clicks during a transition; a timer matching the CSS duration resets the spent view and unlocks. The demo layers on a thin top progress bar and rewrites a fake address-bar URL so the swap reads convincingly as "navigation".

## Customization

- **Speed & feel:** change the `.55s` duration and the easing. ~250–600ms reads as a page transition; faster feels like a tab switch, slower feels heavy.
- **Direction:** flip the sign of `translateY` (or switch to `translateX`) to drift up/down/left/right; set it to `0` for a pure dissolve.
- **Real SPA:** call `go()` from your router's route-change event and fetch/inject the next view's markup instead of pre-stacking — the class choreography is identical.
- **Overlay "wipe":** instead of cross-fading the content, fade a full-bleed colour panel in and back out over the swap.

## Accessibility & performance

- Only `opacity` and `transform` animate — both GPU-compositable, so the effect is essentially free (no layout or paint thrash). That's why Performance cost is 1/5.
- **Always honour `prefers-reduced-motion`:** drop the drift and shorten/disable the fade so motion-sensitive users get an instant swap. (The catalog also applies a global reduced-motion rule that zeroes durations.)
- Transitioning `visibility` rather than `display` lets the outgoing view stay in the accessibility tree until it's truly gone, and removes hidden views from tab order. Mirror it with `aria-hidden` on the views and `aria-current="page"` on the active link.
- Stacking the views in one grid cell keeps the container at the tallest view's height, so the page doesn't jump as content cross-fades.

## Gotchas

- **`display:none` cannot transition.** Use `opacity` + `visibility` (or absolute positioning), not `display`, or the fade simply won't run.
- **Re-entering a view needs a reflow.** Without `void el.offsetWidth` between the class changes the browser may batch them and skip the enter animation entirely.
- **Height jump** if views are siblings (not stacked) with different heights — overlap them in one grid cell, or animate the container's height.
- **Lock during the transition.** Without a `busy` flag, rapid clicks leave two views half-faded or stuck mid-state.
- Drive sequencing with your own JS timer rather than relying only on `transitionend`: a global reduced-motion rule (or a swap that hides the element) can keep `transitionend` from firing when you expect it.
