April 8, 2026/Advanced/Page Transitions

Curve SVG Wipe

A curved SVG shape sweeps up across the screen between pages. The path morphs from a wave shape to a flat edge as it exits, creating a fluid organic transition. Window dimensions are used to calculate the SVG path dynamically.

Home

"use client";

import { useState, useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import styles from "./styles.module.css";

const PAGES = [
  { key: "home", label: "Home", bg: "#f5f5f5", color: "#1a1a1a" },
  { key: "about", label: "About", bg: "#0d0d0d", color: "#ffffff" },
  { key: "work", label: "Work", bg: "#1a1f2e", color: "#ffffff" },
];

function CurveOverlay({ label, onExitComplete }) {
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  useEffect(() => {
    setDimensions({ width: window.innerWidth, height: window.innerHeight });
  }, []);

  const { width: w, height: h } = dimensions;
  if (!w) return null;

  const initialPath = `M0 300 Q${w / 2} 0 ${w} 300 L${w} ${h + 300} Q${w / 2} ${h + 600} 0 ${h + 300} L0 0`;
  const targetPath = `M0 300 Q${w / 2} 0 ${w} 300 L${w} ${h} Q${w / 2} ${h} 0 ${h} L0 0`;

  return (
    <motion.div
      className={styles.overlay}
      initial={{ top: "100vh" }}
      animate={{ top: 0, transition: { duration: 0.6, ease: [0.76, 0, 0.24, 1] } }}
      exit={{ top: "-100vh", transition: { duration: 0.6, ease: [0.76, 0, 0.24, 1], delay: 0.1 } }}
      onAnimationComplete={onExitComplete}
    >
      <svg
        className={styles.svg}
        viewBox={`0 0 ${w} ${h + 300}`}
        preserveAspectRatio="none"
      >
        <motion.path
          d={initialPath}
          animate={{ d: targetPath }}
          transition={{ duration: 0.6, ease: [0.76, 0, 0.24, 1] }}
          fill="#1a1a1a"
        />
      </svg>
      <div className={styles.routeLabel}>{label}</div>
    </motion.div>
  );
}

export default function CurveSvgWipe() {
  const [pageIndex, setPageIndex] = useState(0);
  const [displayIndex, setDisplayIndex] = useState(0);
  const [showOverlay, setShowOverlay] = useState(false);
  const [pendingIndex, setPendingIndex] = useState(null);

  const page = PAGES[displayIndex];

  function navigate(nextIndex) {
    if (showOverlay || nextIndex === pageIndex) return;
    setPendingIndex(nextIndex);
    setPageIndex(nextIndex);
    setShowOverlay(true);
  }

  function handleOverlayExit() {
    if (pendingIndex !== null) {
      setDisplayIndex(pendingIndex);
      setPendingIndex(null);
    }
    setTimeout(() => setShowOverlay(false), 100);
  }

  return (
    <div className={styles.demo}>
      <div className={styles.viewport}>
        <div
          className={styles.page}
          style={{ background: page.bg, color: page.color }}
        >
          <h2 className={styles.pageTitle}>{page.label}</h2>
        </div>

        <AnimatePresence>
          {showOverlay && (
            <CurveOverlay
              key={pageIndex}
              label={PAGES[pageIndex].label}
              onExitComplete={handleOverlayExit}
            />
          )}
        </AnimatePresence>
      </div>

      <div className={styles.nav}>
        {PAGES.map((p, i) => (
          <button
            key={p.key}
            className={`${styles.navBtn} ${i === pageIndex ? styles.active : ""}`}
            onClick={() => navigate(i)}
          >
            {p.label}
          </button>
        ))}
      </div>
    </div>
  );
}
  • framer-motion

Related Components

1W AGO
Signifly Theme Fadepage-transitions
3MO AGO
Section Transition Systempage-transitions
3MO AGO
Shutter Sectionpage-transitions