---
name: route-transition-animation
description: Use when you want "Professional, app-like, polished" - Animates changes between app routes.
---

# Route Transition Animation

> **Category:** Loading / Transition  -  **Personality:** Professional, app-like, polished
>
> **Best use:** React/Vue/Next apps
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

A route transition animates the swap between "pages" of a single-page app instead of cutting hard. The app shell (sidebar, top bar, URL bar) stays put while only the content region animates: the outgoing view fades + slides up + scales down slightly, then the incoming view fades + slides + scales back in, usually with a thin "loading" bar racing across the top (NProgress-style) and a shared active-pill that glides between nav items. It's the detail that makes a web app feel native rather than like a stack of documents.

The demo is a faux SPA ("Cadence") with four routes — Overview, Projects, Insights, Team. **There is no real navigation:** clicking a nav item swaps `innerHTML`, animates the view out then in, slides the shared pill, and updates a fake URL/breadcrumb. Back/forward buttons traverse an in-memory history stack.

## Dependencies / CDN

**None - pure CSS animations + vanilla JS.** The demo loads two display fonts (optional):

```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=Familjen+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```

## HTML

The persistent shell + the single region that gets re-rendered (`.rt-view`):

```html
<div class="rt-stage">
  <div class="rt-app">
    <span class="rt-progress"></span>            <!-- top loading bar -->
    <div class="rt-chrome">
      <button class="rt-ico" data-rt="back" disabled>‹</button>
      <button class="rt-ico" data-rt="fwd"  disabled>›</button>
      <button class="rt-ico" data-rt="reload">⟳</button>
      <span class="rt-url">app.cadence.io<span class="rt-url-path">/overview</span></span>
    </div>
    <div class="rt-body">
      <nav class="rt-menu">
        <span class="rt-ind"></span>             <!-- shared sliding pill -->
        <button class="rt-item is-active">Overview</button>
        <button class="rt-item">Projects</button>
        <button class="rt-item">Insights</button>
        <button class="rt-item">Team</button>
      </nav>
      <section class="rt-content">
        <div class="rt-bread">Cadence / <b class="rt-crumb">Overview</b></div>
        <div class="rt-view" role="region" aria-live="polite"><!-- swapped per route --></div>
      </section>
    </div>
  </div>
</div>
```

## CSS

The whole effect is three keyframes + two classes the JS toggles on `.rt-view`. The sliding pill and load bar are pure transitions.

```css
/* outgoing / incoming view */
.rt-view.is-leaving { animation: rt-exit  .24s cubic-bezier(.4,0,1,1)   forwards; }
.rt-view.is-entering{ animation: rt-enter .46s cubic-bezier(.16,1,.3,1) forwards; }

@keyframes rt-exit { from{opacity:1; transform:none}
                     to  {opacity:0; transform:translateY(-12px) scale(.975); filter:blur(3px)} }
@keyframes rt-enter{ from{opacity:0; transform:translateY(16px) scale(.985); filter:blur(2px)}
                     to  {opacity:1; transform:none; filter:blur(0)} }

/* freshly-rendered cards rise with a stagger (replays on every render — they are new nodes) */
.rt-card { animation: rt-rise .54s cubic-bezier(.16,1,.3,1) backwards;
           animation-delay: calc(70ms + var(--i,0) * 70ms); }
@keyframes rt-rise{ from{opacity:0; transform:translateY(14px)} to{opacity:1; transform:none} }

/* shared active-pill — JS sets transform/size from the active item's box */
.rt-ind { position:absolute; top:0; left:0;
  transition: transform .44s cubic-bezier(.34,1.45,.42,1),
              width .44s cubic-bezier(.34,1.45,.42,1), height .3s ease; }

/* NProgress-style route bar (JS drives width: 0 -> 72% -> 100% -> fade) */
.rt-progress { position:absolute; top:0; left:0; height:2.5px; width:0; opacity:0;
  background:linear-gradient(90deg,#6366f1,#a78bfa);
  transition: width .42s cubic-bezier(.4,0,.2,1), opacity .3s; }

@media (prefers-reduced-motion: reduce){
  .rt-view.is-leaving, .rt-view.is-entering, .rt-card{ animation:none !important; }
  .rt-ind, .rt-progress{ transition:none; }
}
```

## JavaScript

`routes` holds each view's HTML string + fake path. `go()` orchestrates exit → render → enter, listening for the view's *own* `animationend` so the swap happens at the seam.

```js
const stage = document.querySelector('.rt-stage');
const view  = stage.querySelector('.rt-view');
const ind   = stage.querySelector('.rt-ind');
const items = [...stage.querySelectorAll('.rt-item')];
const bar   = stage.querySelector('.rt-progress');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');

const routes = [
  { path:'/overview', crumb:'Overview', html:null },  // captured from initial DOM
  { path:'/projects', crumb:'Projects', html:'<h2>Projects</h2>…' },
  { path:'/insights', crumb:'Insights', html:'<h2>Insights</h2>…' },
  { path:'/team',     crumb:'Team',     html:'<h2>Team</h2>…' },
];
routes[0].html = view.innerHTML;            // overview ships in HTML (no-JS fallback)

let current = 0, busy = false, hist = [0], hi = 0, pillReady = false;

function placeIndicator(i = current){       // measure active item's box, move pill onto it
  const el = items[i]; if (!el) return;
  if (!pillReady) ind.style.transition = 'none';        // no animate on first paint
  ind.style.width  = el.offsetWidth  + 'px';
  ind.style.height = el.offsetHeight + 'px';
  ind.style.transform = `translate(${el.offsetLeft}px,${el.offsetTop}px)`;
  if (!pillReady) { void ind.offsetWidth; ind.style.transition = ''; pillReady = true; }
}
function render(i){                          // swap content + fake URL + breadcrumb
  view.innerHTML = routes[i].html;
  stage.querySelector('.rt-url-path').textContent = routes[i].path;
  stage.querySelector('.rt-crumb').textContent    = routes[i].crumb;
}
function startBar(){ bar.style.transition='none'; bar.style.width='0'; bar.style.opacity='1';
  void bar.offsetWidth; bar.style.transition='width .42s cubic-bezier(.4,0,.2,1),opacity .3s'; bar.style.width='72%'; }
function endBar(){ bar.style.width='100%';
  setTimeout(()=>{ bar.style.opacity='0'; setTimeout(()=>{ bar.style.transition='none'; bar.style.width='0'; },320); },180); }

// fire fn when the element's OWN animation ends (ignore child animations that bubble up)
function onSelfAnimEnd(el, fn){ el.addEventListener('animationend', function h(e){
  if (e.target !== el) return; el.removeEventListener('animationend', h); fn(); }); }

function go(i, opts = {}){
  if (busy || (i === current && !opts.force)) return;
  busy = true;
  if (!opts.fromHistory){ hist = hist.slice(0, hi+1); hist.push(i); hi = hist.length-1; }
  setActive(i); placeIndicator(i); startBar();          // pill glides immediately
  if (reduce.matches){ render(i); current=i; endBar(); busy=false; return; }
  view.classList.add('is-leaving');
  onSelfAnimEnd(view, () => {
    view.classList.remove('is-leaving');
    render(i); current = i;                              // swap at the seam
    void view.offsetWidth;                               // force restart
    view.classList.add('is-entering');
    endBar();
    onSelfAnimEnd(view, () => { view.classList.remove('is-entering'); busy = false; });
  });
}

items.forEach((it, k) => it.addEventListener('click', () => go(k)));
// back / forward traverse the history stack (force:true so an equal index still re-renders)
backBtn.onclick = () => { if (!busy && hi > 0)              go(hist[--hi], {fromHistory:true, force:true}); };
fwdBtn.onclick  = () => { if (!busy && hi < hist.length-1)  go(hist[++hi], {fromHistory:true, force:true}); };
addEventListener('resize', () => placeIndicator());        // keep pill aligned
```

## How it works

- **One region, two animations.** Only `.rt-view` moves. `is-leaving` runs `rt-exit` (fade + lift + shrink + slight blur); when *its own* `animationend` fires, JS replaces `innerHTML`, forces a reflow (`void view.offsetWidth`), then adds `is-entering` to run `rt-enter`. Swapping at the seam keeps old and new content from ever overlapping.
- **`onSelfAnimEnd` guards `e.target === el`** so the staggered child-card animations (which bubble `animationend` up to the view) don't prematurely trigger the swap.
- **Shared pill = measure, don't reflow.** `placeIndicator` reads the active item's `offsetLeft/Top/Width/Height` and writes them to an absolutely-positioned element whose `transform`/size transition. Because it's driven off the real box it works for a vertical sidebar *and* a horizontal mobile tab bar with no code change.
- **The load bar** is faked exactly like NProgress: jump to ~72% on start, ease to 100% and fade on finish — it reads as "fetching" even though nothing is fetched.
- **`busy` flag** ignores clicks mid-transition so overlapping navigations can't desync the history stack.

## Customization

- **Personality of the move:** subtle/corporate → drop the `scale()` and `blur()`, shrink the travel to `translateY(8px)`. Playful → bump the enter easing to an overshoot like `cubic-bezier(.34,1.56,.64,1)`.
- **Direction:** for an iOS-style push, exit `translateX(-30px)` and enter from `translateX(30px)`.
- **Speed:** keep exit shorter than enter (here .24s vs .46s) — fast out, soft in feels responsive.
- **Stagger:** tune the per-card `--i * 70ms`; set `--i` inline on each child.
- **Pill feel:** the spring is in its `cubic-bezier(.34,1.45,.42,1)`; flatten it for a corporate slide.

## Accessibility & performance

- Animate only **`opacity` / `transform`** (and one cheap `blur`) — all GPU-compositable, so transitions stay at 60fps and cost ~nothing. Never animate `width`/`height`/`top` of the view.
- **Honor `prefers-reduced-motion`:** the CSS kills the animations and the JS takes an instant-swap branch (`reduce.matches`) so it never waits on an `animationend` that will not come.
- Mark the swapped region `role="region" aria-live="polite"` so screen readers announce the new view; keep `aria-current="page"` in sync on the active nav item.
- In a *real* SPA, also move focus to the new view's heading on navigation and update `document.title`.

## Gotchas

- **`animationend` bubbles.** Child elements that also animate will fire it on the parent — always check `e.target === view` or the swap fires early. (This is the #1 bug in hand-rolled route transitions.)
- **Force a reflow between the two classes** (`void view.offsetWidth`) or the browser batches remove+add and the enter animation never restarts.
- **Reduced motion + `animationend` = hang.** If animations are disabled the event may never fire; branch to a synchronous swap instead of awaiting it.
- **Lock during transition.** Without the `busy` guard, fast clicks interleave exit/enter and corrupt the history stack.
- **Measure the pill after layout settles** — call `placeIndicator` on `requestAnimationFrame` and again on `document.fonts.ready`, since web-font swap changes item widths.
- **Keep the view height stable** (a `min-height`, or a real router that preloads) so the shell doesn't jump as routes of different heights swap in.
