Page Transition Fade
The outgoing view sinks a few pixels as it fades while the incoming one rises to meet it.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
Opening a different page settles the body content in without a blank frame between.
Related patterns
- Image Blur UpA tiny blurred stand-in holds the frame and sharpens into the full picture, with nothing below it moving.
- Carousel Preload SlideThe upcoming frame resolves from placeholder to artwork a beat before the track moves to it.
- Cache Hit InstantCached content gets no entrance at all; only the values that actually changed animate.