---
name: page-transition-slide
description: Use when you want "App-like, modern, clear" - Slides pages or sections during navigation.
---

# Page Transition Slide

> **Category:** Loading / Transition  -  **Personality:** App-like, modern, clear
>
> **Best use:** SPAs, mobile-like interfaces
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 2/5

## What it is

Instead of cutting between screens, the outgoing view **slides off one edge while the incoming view slides in from the opposite edge**. The crucial detail is that *direction encodes hierarchy*: moving forward (into a detail, or to a later tab) pushes content **left**, going back returns it **right**. That single rule makes navigation feel spatial and self-explanatory — it is the signature feel of native iOS/Android stack navigation and polished SPA route transitions. Reach for it on app-like, mobile, or wizard UIs where the user moves through a small set of full screens.

The demo is a self-contained mini-app ("Wayfare") with a persistent status bar and bottom tab bar; switching tabs and drilling into a stay both slide directionally, with no real page navigation. A "Play tour" button scripts a forward/back storyboard so the effect is obvious at a glance.

## Dependencies / CDN

**None — pure CSS `transform`/`transition` + vanilla JS.** No libraries.

The demo only loads 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=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Sora:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

A clipped **viewport** holding stacked screens, plus any persistent chrome (status/tab bar) kept *outside* the viewport so only the content slides:

```html
<div class="view">                                 <!-- the clipping window -->
  <section class="screen is-on" id="home">…</section>
  <section class="screen"        id="detail">…</section>
  <section class="screen"        id="saved">…</section>
</div>

<nav class="tabs">                                  <!-- persistent: stays put -->
  <button data-screen="home"   data-index="0">Home</button>
  <button data-screen="saved"  data-index="1">Saved</button>
  <button data-screen="profile" data-index="2">Profile</button>
</nav>
```

## CSS

```css
.view{ position:relative; overflow:hidden }          /* clips screens parked off-screen */

.screen{
  position:absolute; inset:0;                        /* all screens stacked, same box   */
  transform:translateX(100%); visibility:hidden;     /* parked off-screen to the right   */
  transition:transform .46s cubic-bezier(.22,.61,.36,1);
  will-change:transform;
}
.screen.is-on{ transform:translateX(0); visibility:visible }   /* the one live screen */

/* motion is non-essential — collapse it to an instant swap */
@media (prefers-reduced-motion:reduce){ .screen{ transition-duration:.001ms } }
```

`visibility:hidden` (not `display:none`) is deliberate: it keeps `transform` transitionable and removes off-screen screens from the tab order.

## JavaScript

The whole effect is one router. `dir` is `'fwd'` or `'back'`; the **reflow** (`void to.offsetWidth`) is what lets a freshly-positioned element animate.

```js
var animating = false, timer = null;
var RM = matchMedia('(prefers-reduced-motion: reduce)').matches;

function show(toId, dir){                              // dir: 'fwd' | 'back'
  if (animating) return false;
  var to   = document.getElementById(toId);
  var from = document.querySelector('.screen.is-on');
  if (!to || to === from) return false;
  animating = true;

  // 1) park the incoming screen on the side we are travelling FROM
  to.style.transition = 'none';
  to.style.transform  = 'translateX(' + (dir === 'back' ? -100 : 100) + '%)';
  to.classList.add('is-on');                           // make it visible
  void to.offsetWidth;                                 // 2) FORCE REFLOW — commit the start offset

  // 3) re-enable the transition, then animate both screens to their new spots
  to.style.transition = '';
  to.style.transform  = 'translateX(0)';
  if (from){
    from.style.transition = '';
    from.style.transform  = 'translateX(' + (dir === 'back' ? 100 : -100) + '%)';
  }

  // 4) settle after the slide (a timeout works even when duration is 0)
  clearTimeout(timer);
  timer = setTimeout(function(){
    to.style.transition = ''; to.style.transform = '';      // fall back to the .is-on class
    if (from){
      from.style.transition = 'none';                       // reset off-screen with no flash
      from.classList.remove('is-on');
      from.style.transform = '';
      void from.offsetWidth; from.style.transition = '';
    }
    animating = false;
  }, RM ? 40 : 500);
  return true;
}

// Driver: a later tab is "forward", an earlier tab is "back".
var cur = 0;
document.querySelectorAll('.tabs button').forEach(function(b){
  b.addEventListener('click', function(){
    var i = +b.dataset.index;
    if (i === cur) return;
    if (show(b.dataset.screen, i > cur ? 'fwd' : 'back')) cur = i;
  });
});
// Drill-down is always 'fwd'; its back button is always 'back'.
```

## How it works

- **Every screen shares one box** (`position:absolute; inset:0`); only its `transform` differs, so swapping screens never reflows layout.
- **Direction is just the sign of the offset.** Forward: incoming starts at `+100%` and slides to `0`, outgoing leaves to `-100%`. Back: mirror it (`-100%` → `0`, outgoing to `+100%`).
- **The reflow is mandatory.** Set the start offset with the transition switched *off*, read `offsetWidth` to force the browser to commit that position, then turn the transition back on and set the target. Without the reflow the browser collapses both writes into a single style change and the element teleports.
- **Persistent chrome lives outside `.view`**, so the status bar and tab bar stay fixed while only the content area slides — exactly how native apps behave.
- **Cleanup runs on a timer** rather than `transitionend`, because a 0-duration transition (reduced motion) never fires that event.

## Customization

- **Feel:** tune `duration` (240–460ms is the app sweet spot) and the cubic-bezier. A slight ease-out (`cubic-bezier(.22,.61,.36,1)`) reads as "native".
- **iOS push parallax:** move the outgoing screen only `-30%` and add `filter:brightness(.85)` or a small `scale(.96)` so the layer beneath peeks through.
- **Modals / wizards:** swap `translateX` for `translateY(100%)` to slide up from the bottom.
- **Axis per route:** keep a depth counter and pick `fwd`/`back` automatically (deeper route = forward), so any link "just knows" its direction.
- **Gestural back:** map a horizontal pointer drag to the outgoing screen's `translateX` and commit/cancel on release.

## Accessibility & performance

- **Only `transform` is animated** — it runs on the compositor, so even full-screen slides stay at 60fps and cost little (no layout/paint per frame).
- **Honour `prefers-reduced-motion`:** the CSS collapses the slide to an instant swap and the JS shortens its cleanup timer, so navigation still works with zero motion.
- **Manage `aria-hidden` + visibility:** the live screen is `aria-hidden="false"`, parked screens `aria-hidden="true"` and `visibility:hidden`, so screen readers and keyboard focus never reach an off-screen view.
- **Re-entrancy guard** (`animating`) prevents overlapping slides from a rapid double-tap.
- For very large apps, mount/unmount or virtualize inactive screens instead of keeping every DOM tree alive.

## Gotchas

- **No reflow = no animation.** `void el.offsetWidth` between the start-offset write and the target write is the single most common thing people miss.
- **Use `visibility:hidden`, not `display:none`.** `display:none` cannot be transitioned and would make the incoming screen jump.
- **Clip the viewport.** Without `overflow:hidden` on the parent, the screens parked at `±100%` create horizontal scrollbars.
- **Animate `transform`, never `left`/`margin`/`width`** — those trigger layout every frame and stutter.
- **`transitionend` is unreliable at 0ms.** Under reduced motion (or `display:none`) it may never fire; drive cleanup with a timeout (as above) or you'll leave a stale screen visible.
- **Reset the outgoing screen with its transition off**, or it visibly sweeps back across the viewport when you re-park it.
