AnimationsAdvancedApril 8, 2026
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.
View Full Demo →Preview
Source
index.jsx
"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>
);
}
styles.module.css
.demo {
display: flex;
flex-direction: column;
}
.viewport {
position: relative;
height: 320px;
overflow: hidden;
}
.page {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.pageTitle {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.03em;
}
.overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.routeLabel {
position: relative;
z-index: 1;
font-size: 1.25rem;
font-weight: 600;
color: #ffffff;
letter-spacing: -0.02em;
}
.nav {
display: flex;
gap: 1px;
background: #e5e5e5;
border-top: 1px solid #e5e5e5;
}
.navBtn {
flex: 1;
padding: 0.875rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
background: #ffffff;
border: none;
cursor: pointer;
color: #6b6b6b;
transition: background 150ms ease, color 150ms ease;
}
.navBtn:hover {
background: #f5f5f5;
color: #1a1a1a;
}
.navBtn.active {
background: #1a1a1a;
color: #ffffff;
}
Dependencies
framer-motion