Page transitions make navigation feel polished. Here is how to implement them with Framer Motion in Next.js App Router.
Install
pnpm add motion
Template-Based Transitions
The Next.js template.tsx file re-mounts on navigation, making it perfect for page transitions.
// app/(site)/template.tsx
"use client";
import { motion } from "motion/react";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{children}
</motion.div>
);
}
Fade Transition
"use client";
import { motion } from "motion/react";
export function FadeTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
>
{children}
</motion.div>
);
}
Slide Transition
"use client";
import { motion } from "motion/react";
type Direction = "left" | "right" | "up" | "down";
const variants = {
left: { initial: { x: "100%" }, animate: { x: 0 }, exit: { x: "-100%" } },
right: { initial: { x: "-100%" }, animate: { x: 0 }, exit: { x: "100%" } },
up: { initial: { y: "100%" }, animate: { y: 0 }, exit: { y: "-100%" } },
down: { initial: { y: "-100%" }, animate: { y: 0 }, exit: { y: "100%" } },
};
export function SlideTransition({
children,
direction = "left",
}: {
children: React.ReactNode;
direction?: Direction;
}) {
const v = variants[direction];
return (
<motion.div
initial={v.initial}
animate={v.animate}
exit={v.exit}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{children}
</motion.div>
);
}
Staggered Content Animation
"use client";
import { motion } from "motion/react";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
export function StaggeredPage({ children }: { children: React.ReactNode }) {
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
{children}
</motion.div>
);
}
export function StaggeredItem({ children }: { children: React.ReactNode }) {
return <motion.div variants={itemVariants}>{children}</motion.div>;
}
// Usage
function BlogPage() {
return (
<StaggeredPage>
<StaggeredItem>
<h1>Blog</h1>
</StaggeredItem>
<StaggeredItem>
<p>Latest articles</p>
</StaggeredItem>
{posts.map((post) => (
<StaggeredItem key={post.slug}>
<PostCard post={post} />
</StaggeredItem>
))}
</StaggeredPage>
);
}
Shared Layout Animations
"use client";
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
import { useState } from "react";
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
export function AnimatedTabs({ tabs }: { tabs: Tab[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
return (
<LayoutGroup>
<div className="border-b flex gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="relative px-4 py-2 text-sm font-medium"
>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="active-tab"
className="absolute inset-x-0 bottom-0 h-0.5 bg-primary"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
</button>
))}
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className="py-4"
>
{tabs.find((t) => t.id === activeTab)?.content}
</motion.div>
</AnimatePresence>
</LayoutGroup>
);
}
Scroll-Triggered Animations
"use client";
import { motion, useInView } from "motion/react";
import { useRef } from "react";
export function ScrollReveal({
children,
delay = 0,
}: {
children: React.ReactNode;
delay?: number;
}) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-50px" });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 40 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 40 }}
transition={{ duration: 0.5, delay, ease: "easeOut" }}
>
{children}
</motion.div>
);
}
Route Transition with Progress Bar
"use client";
import { motion, useScroll } from "motion/react";
export function ScrollProgress() {
const { scrollYProgress } = useScroll();
return (
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX: scrollYProgress }}
/>
);
}
Need Polished Web Animations?
We create smooth, performant animations that enhance user experience without hurting performance. Contact us to add motion to your site.