All patterns

Page Transition Fade

The outgoing view sinks a few pixels as it fades while the incoming one rises to meet it.

loadingelegantpremiumautomatic · finite · starter · ~0.4s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

239 lines · react + motion only
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Page Transition Fade
 *
 * A route change with a direction: the outgoing view drops a few pixels
 * as it fades, the incoming view rises to meet it. The two overlap in a
 * single grid cell, so the frame never collapses between them.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the frame reads
 * correctly on a light page and on a dark one.
 * Works with zero props; pass `index` to drive it from your router and
 * `pages` to render your own views.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PageTransitionFadeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Active page. Left undefined, the component cycles its own sample pages. */
  index?: number;
  /** Your views. Falls back to embedded sample pages. */
  pages?: ReactNode[];
  /** Only consulted while `index` is undefined. */
  autoAdvanceMs?: number;
  /** Frame width — px number or any CSS length. */
  width?: number | string;
  /** Reserved height, so a shorter view can't shrink the frame mid-swap. */
  minHeight?: number;
};

type VariantConfig = {
  /** Where the incoming view starts, in px below its resting place. */
  rise: number;
  /** How far the outgoing view sinks as it leaves. */
  sink: number;
  enterSeconds: number;
  exitSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: travel is measured in single-digit pixels. A page
// transition is chrome — it should be felt as a change of place, not
// watched. Springs sit above critical damping so headings arrive once;
// variants differ in travel and speed, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a straight cross-fade. For dense app shells where the frame
  // changes often and the user is navigating quickly.
  subtle: {
    rise: 6,
    sink: 3,
    enterSeconds: 0.26,
    exitSeconds: 0.16,
    spring: { type: "spring", stiffness: 560, damping: 46 },
  },
  // Enough travel to read a hand-off. The all-purpose setting.
  default: {
    rise: 11,
    sink: 6,
    enterSeconds: 0.34,
    exitSeconds: 0.2,
    spring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // A longer, slower arrival — for a marketing or settings surface where
  // each view is a destination rather than a step.
  playful: {
    rise: 17,
    sink: 9,
    enterSeconds: 0.42,
    exitSeconds: 0.24,
    spring: { type: "spring", stiffness: 380, damping: 35 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  mixing it with `transparent` yields rules and chips correctly toned on
 *  light and dark pages. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function PageTransitionFade({
  variant = "default",
  index,
  pages,
  autoAdvanceMs = 2600,
  width = 320,
  minHeight = 186,
}: PageTransitionFadeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const views = pages ?? SAMPLE_PAGES;
  const [selfIndex, setSelfIndex] = useState(0);

  // Uncontrolled by default so the file runs on its own; the moment a
  // caller passes `index`, this timer stays out of the way.
  useEffect(() => {
    if (index !== undefined) return;
    const timer = setInterval(
      () => setSelfIndex((current) => (current + 1) % views.length),
      autoAdvanceMs
    );
    return () => clearInterval(timer);
  }, [index, autoAdvanceMs, views.length]);

  const active = (index ?? selfIndex) % views.length;

  // Reduced motion keeps the hand-off — one view replaces another — and
  // drops only the travel that carries it.
  const rise = reduceMotion ? 0 : cfg.rise;
  const sink = reduceMotion ? 0 : cfg.sink;

  return (
    <div
      style={{
        width,
        minHeight,
        // Both views share one grid cell: the frame is sized by the taller
        // of the two while they overlap, so the swap can never collapse the
        // page and shove whatever sits below it.
        display: "grid",
        alignItems: "start",
        overflow: "hidden",
      }}
    >
      <AnimatePresence initial={false}>
        <motion.div
          key={active}
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: sink }}
          transition={{
            opacity: {
              duration: reduceMotion ? 0.18 : cfg.enterSeconds,
              ease: "easeOut",
            },
            y: reduceMotion ? { duration: 0 } : cfg.spring,
          }}
          style={{ gridArea: "1 / 1" }}
        >
          {views[active]}
        </motion.div>
      </AnimatePresence>
    </div>
  );
}

/** One sample view. Local component so every page has the same rhythm —
 *  a transition reads as a change of place only when the frame around it
 *  stays recognisably the same. */
function SamplePage({
  eyebrow,
  title,
  rows,
}: {
  eyebrow: string;
  title: string;
  rows: { label: string; value: string }[];
}) {
  return (
    <div>
      <div
        style={{
          fontSize: 11,
          fontWeight: 600,
          letterSpacing: 0.5,
          opacity: 0.5,
        }}
      >
        {eyebrow}
      </div>
      <div style={{ fontSize: 17, fontWeight: 620, marginTop: 5 }}>{title}</div>
      <div style={{ marginTop: 14 }}>
        {rows.map((row, rowIndex) => (
          <div
            key={row.label}
            style={{
              display: "flex",
              alignItems: "baseline",
              justifyContent: "space-between",
              gap: 12,
              padding: "9px 0",
              borderTop: rowIndex === 0 ? "none" : `1px solid ${tone(9)}`,
            }}
          >
            <span style={{ fontSize: 13, opacity: 0.78 }}>{row.label}</span>
            <span
              style={{
                fontSize: 13,
                fontWeight: 600,
                fontVariantNumeric: "tabular-nums",
                fontFeatureSettings: '"tnum"',
              }}
            >
              {row.value}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}

/** Embedded sample so the component demonstrates itself with zero props.
 *  Replace it by passing `pages`. */
const SAMPLE_PAGES: ReactNode[] = [
  <SamplePage
    key="overview"
    eyebrow="WORKSPACE"
    title="Billing overview"
    rows={[
      { label: "Current plan", value: "Scale" },
      { label: "Seats in use", value: "42 of 50" },
      { label: "Next invoice", value: "Sep 1" },
    ]}
  />,
  <SamplePage
    key="invoices"
    eyebrow="BILLING"
    title="Invoices"
    rows={[
      { label: "INV-2041 · Aug", value: "$4,820" },
      { label: "INV-2038 · Jul", value: "$4,640" },
      { label: "INV-2034 · Jun", value: "$4,640" },
    ]}
  />,
  <SamplePage
    key="usage"
    eyebrow="BILLING"
    title="Usage this cycle"
    rows={[
      { label: "API requests", value: "1.24M" },
      { label: "Storage", value: "318 GB" },
      { label: "Overage", value: "$0.00" },
    ]}
  />,
];

About this pattern

A route change with a direction. The leaving view drops three to nine pixels as its opacity goes, the arriving view starts the same distance below its resting place and lifts into it, and the two overlap rather than taking turns — a fade-out-then-fade-in leaves an empty frame in the middle, which is the moment that makes an app feel slow. Both views live in one grid cell, so the frame is sized by the taller of them while they cross and can never collapse and shove the rest of the page. Travel is single-digit pixels on a spring above critical damping: chrome should be felt, not watched.

Route changeDashboard section swapSettings pane changeDocumentation page navigation

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    Opening a different page settles the body content in without a blank frame between.

Related patterns