Skip to content
ARDENT / MOTION LAB
Library

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→