Page Transitions · Intermediate
Curtain Page Transition
AnimatePresence in wait mode sequences exit and enter. A full-bleed panel scales on Y to cover and reveal, then the headline enters.
Live preview
Choose Home, Work or Contact to trigger the curtain.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Page Transition
* A restrained curtain separates three self-contained editorial pages.
* Requires: motion, Tailwind with shadcn semantic tokens.
*/
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const pages = [
{
name: "Home",
title: "Quiet power.",
seed: "home",
detail: "Independent thinking. Enduring outcomes.",
},
{
name: "Work",
title: "Made to matter.",
seed: "work",
detail: "Selected projects, considered in every detail.",
},
{
name: "Contact",
title: "Begin something.",
seed: "contact",
detail: "The best work starts with a conversation.",
},
];
export default function PageTransition({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useReducedMotion();
const reduced = reducedProp ?? systemReduced ?? false;
const amount = Math.min(1.5, Math.max(0.5, intensity));
const [index, setIndex] = useState(0);
const page = pages[index] ?? pages[0]!;
return (
<section className="flex min-h-[80vh] items-center justify-center bg-background px-6 py-20 text-foreground">
<div className="w-full max-w-6xl overflow-hidden rounded-2xl border border-border bg-card">
<nav
aria-label="Preview pages"
className="flex flex-wrap gap-6 border-b border-border px-6 py-5"
>
{pages.map((entry, pageIndex) => (
<button
key={entry.name}
type="button"
aria-current={index === pageIndex ? "page" : undefined}
onClick={() => setIndex(pageIndex)}
className={`rounded-sm font-mono text-xs uppercase tracking-[0.2em] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 ${index === pageIndex ? "text-foreground" : "text-muted-foreground"}`}
>
{entry.name}
</button>
))}
</nav>
<AnimatePresence mode="wait">
<motion.article
key={page.name}
className="relative grid min-h-[55vh] overflow-hidden sm:grid-cols-2"
initial={false}
animate={{ opacity: 1 }}
exit={{ opacity: reduced ? 1 : 0 }}
transition={{ duration: reduced ? 0 : 0.18 }}
>
<div className="flex flex-col justify-end px-6 py-12 sm:p-10">
<p className="mb-6 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Ardent / {page.name}
</p>
<motion.h2
className="font-serif text-5xl tracking-tight lg:text-7xl"
initial={reduced ? false : { opacity: 0, y: 32 * amount }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.8, delay: reduced ? 0 : 0.2 }}
>
{page.title}
</motion.h2>
<p className="mt-6 max-w-xs text-sm leading-relaxed text-muted-foreground">
{page.detail}
</p>
</div>
<img
src={`https://picsum.photos/seed/ardent-${page.seed}/900/1100`}
alt={`Photographic editorial study accompanying the ${page.name.toLowerCase()} page.`}
className="h-64 w-full object-cover sm:h-full"
/>
{!reduced && (
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-0 origin-top bg-foreground"
initial={{ scaleY: 1 }}
animate={{ scaleY: 0 }}
transition={{ duration: 0.8 / amount, ease: [0.76, 0, 0.24, 1] }}
/>
)}
</motion.article>
</AnimatePresence>
</div>
</section>
);
}
Next patternScroll-Triggered Counters→