Creating Smooth Page Transitions with Next.js: A Beginner’s Guide

Mohit Singh
Software Engineer
Hey there, web devs! 👋🏼
Ready to make your website feel like a slick, cinematic experience? Today, we’re diving into the world of page transitions using Next.js, plain CSS, and a sprinkle of JavaScript. No fancy frameworks like Framer Motion here — just the good ol’ basics to create smooth, eye-catching transitions between pages. By the end of this guide, you’ll have a portfolio site that slides, fades, and wows like a pro. Let’s get started!
What you can expect outta this blog:
If you prefer to get your hands on code directly, here is the source code for yaa:
https://github.com/CalmNerd/page-transition-0
Step 1: Setting Up Your Next.js Project
First things first, let’s build the foundation. If you’ve never set up a Next.js project before, don’t worry — it’s easier than assembling IKEA furniture.
Initialize the Project
Open your terminal (that black box that makes you feel like a hacker) and run:
npx create-next-app@latest my-portfolio
cd my-portfolio
Follow the prompts:
- TypeScript? Nah, let’s stick with JavaScript for simplicity.
- ESLint? Sure, it keeps our code tidy.
- Tailwind CSS? Not today, we’re going pure CSS.
- App Router? Yes, it’s the modern way.
- Customize import alias? Default is fine.
- keep src folder? As per your choice.
Once done, fire up the dev server:
npm run dev
Open http://localhost:3000 in your browser, and you’ll see the default Next.js page. Congrats, you’re officially a developer now! 🎉
Install Dependencies
We’ll need a few tools to make our transitions smooth:
- next-view-transitions: For handling page transitions.
- @studio-freight/react-lenis: For smooth scrolling.
- gsap and @gsap/react: For animating text.
- split-type: To split text into characters or lines for animations.
Install them with:
npm install next-view-transitions @studio-freight/react-lenis gsap @gsap/react split-type
Your project is now armed and ready. Let’s move on to the fun part: transitions!
Step 2: Adding Transition-Related Code
Before we explain every line, let’s set up the key files that make our transitions work. Think of this as the recipe before we explain why each ingredient matters.
Here this is how your folder structure should look (if you have not selected the src folder):

With that, Let’s write some code or copy-paste it?
Global CSS (globals.css)
This file sets the stage for our site’s look and transition animations.
@import url(https://db.onlinewebfonts.com/c/2ae387e9fd826eca2b02f780e91333c7?family=AkkuratMono-Regular);
:root {
--bg: #1a1a1a;
--copy: #fff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Neue Haas Grotesk Display Pro";
background-color: var(--bg);
color: var(--copy);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.nav {
position: fixed;
top: 0;
left: 0;
width: 100vw;
padding: 1.75em;
display: flex;
justify-content: space-between;
align-items: center;
}
.links {
display: flex;
gap: 2em;
}
a {
text-decoration: none;
text-transform: uppercase;
color: var(--copy);
font-family: "AkkuratMono-Regular";
font-size: 12px;
font-weight: 600;
padding: 0.5em;
}
.home {
width: 100vw;
height: 100svh;
background-color: var(--bg);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.home h1 {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-transform: uppercase;
color: var(--copy);
font-size: 15vw;
font-weight: bolder;
letter-spacing: -0.5rem;
line-height: 1;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
.home h1 .char {
position: relative;
will-change: transform;
}
.info {
width: 100%;
height: 100%;
min-height: 100svh;
background-color: var(--bg);
display: flex;
}
.col {
flex: 1;
}
.col:nth-child(2) {
padding: 2em;
display: flex;
justify-content: center;
align-items: center;
}
.col p {
font-weight: 500;
font-size: 2rem;
color: var(--copy);
}
.col p .line {
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
.col p .line span {
position: relative;
will-change: transform;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
::view-transition-group(root) {
z-index: auto !important;
}
::view-transition-image-pair(root) {
isolation: isolate;
will-change: transform, opacity, clip-path;
z-index: 1;
}
::view-transition-new(root) {
z-index: 10000;
animation: none !important;
}
::view-transition-old(root) {
z-index: 10000;
animation: none !important;
}
NavLink Component (components/NavLink.jsx)
This component handles the custom transition logic for navigation links.
"use client";
import { useTransitionRouter } from "next-view-transitions";
import Link from "next/link";
const transitionFunctions = {
topToBottom: () => {
document.documentElement.animate(
[
{ opacity: 1, transform: "translateY(0)" },
{ opacity: 0.2, transform: "translateY(35%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-old(root)",
}
);
document.documentElement.animate(
[
{ clipPath: "polygon(0 0, 100% 0, 100% 0, 0 0)" },
{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
}
);
},
bottomToTop: () => {
document.documentElement.animate(
[
{ opacity: 1, transform: "translateY(0)" },
{ opacity: 0.2, transform: "translateY(-35%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-old(root)",
}
);
document.documentElement.animate(
[
{ clipPath: "polygon(0 100%, 100% 100%, 100% 100%, 0 100%)" },
{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
}
);
},
leftToRight: () => {
document.documentElement.animate(
[
{ opacity: 1, transform: "translateX(0)" },
{ opacity: 0.2, transform: "translateX(-35%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-old(root)",
}
);
document.documentElement.animate(
[
{ clipPath: "polygon(100% 0, 100% 0, 100% 100%, 100% 100%)" },
{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
}
);
},
rightToLeft: () => {
document.documentElement.animate(
[
{ opacity: 1, transform: "translateX(0)" },
{ opacity: 0.2, transform: "translateX(35%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-old(root)",
}
);
document.documentElement.animate(
[
{ clipPath: "polygon(0 0, 0 0, 0 100%, 0 100%)" },
{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)" },
],
{
duration: 1500,
easing: "cubic-bezier(0.87, 0, 0.13, 1)",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
}
);
},
};
const NavLink = ({ href, children, transitionType = "topToBottom" }) => {
const router = useTransitionRouter();
const handleClick = (e) => {
e.preventDefault();
const slideInOut = transitionFunctions[transitionType] || transitionFunctions.topToBottom;
router.push(href, {
onTransitionReady: slideInOut,
});
};
return (
<Link onClick={handleClick} href={href}>
{children}
</Link>
);
};
export default NavLink;
Root Layout (app/layout.jsx)
This file wraps all pages and includes the navigation and transition setup.
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Nav from "./components/Nav";
import { ViewTransitions } from "next-view-transitions";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata = {
title: "Transitions",
description: "using next-view-transitions",
};
export default function RootLayout({ children }) {
return (
<ViewTransitions>
<html lang="en">
<head>
<style>
@import url('https://fonts.cdnfonts.com/css/neue-haas-grotesk-display-pro');
</style>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<Nav />
{children}
</body>
</html>
</ViewTransitions>
);
}
Navigation Component (components/Nav.jsx)
This component defines the navigation bar.
"use client";
import NavLink from "@/components/NavLink";
const Nav = () => {
return (
<nav className="nav">
<div className="logo">
<div className="links">
<NavLink href="/" transitionType="topToBottom">
Index
</NavLink>
</div>
</div>
<div className="links">
<div className="link">
<NavLink href="/projects" transitionType="leftToRight">Projects</NavLink>
</div>
<div className="link">
<NavLink href="/info" transitionType="rightToLeft">Info</NavLink>
</div>
</div>
</nav>
);
};
export default Nav;
Home Page (app/page.jsx)
The homepage with animated text.
"use client";
import { useRef } from "react";
import ReactLenis from "@studio-freight/react-lenis";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import SplitType from "split-type";
gsap.registerPlugin(useGSAP);
export default function Home() {
const container = useRef();
useGSAP(() => {
const heroText = new SplitType(".home h1", { types: "char" });
gsap.set(heroText.chars, { y: 200 });
gsap.to(heroText.chars, {
y: 0,
duration: 1,
stagger: 0.1,
ease: "Power4.out",
delay: 0.9,
});
}, { scope: container });
return (
<ReactLenis root>
<div className="home" ref={container}>
<h1>Mohit.</h1>
</div>
</ReactLenis>
);
}
Info Page (app/info/page.jsx)
The info page with animated text and an image.
import { useRef } from "react";
import ReactLenis from "@studio-freight/react-lenis";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import SplitType from "split-type";
gsap.registerPlugin(useGSAP);
const Page = () => {
const container = useRef();
useGSAP(() => {
const infoText = new SplitType(".info p", {
types: "lines",
tagName: "div",
lineClass: "line",
});
infoText.lines.forEach((line) => {
const content = line.innerHTML;
line.innerHTML = `<span>${content}</span>`;
});
gsap.set(infoText.lines, {
y: 200,
display: "block",
});
gsap.to(infoText.lines, {
y: 0,
duration: 1.5,
stagger: 0.1,
ease: "Power4.out",
delay: 1,
});
return () => {
if (infoText) infoText.revert();
};
}, { scope: container });
return (
<ReactLenis root>
<div className="info" ref={container}>
<div className="col">
<img src="img2.png" alt="dd" />
</div>
<div className="col">
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Delectus
doloremque vitae nostrum quibusdam excepturi, quos esse aperiam!
Facilis, tempora eligendi?
</p>
</div>
</div>
</ReactLenis>
);
};
export default Page;
Projects Page (app/projects/page.jsx)
A simple projects page with images.
"use client";
import ReactLenis from "@studio-freight/react-lenis";
const page = () => {
return (
<ReactLenis root>
<div className="projects">
<div className="images">
<img src="img1.png" alt="img" />
<img src="img2.png" alt="img" />
<img src="img3.png" alt="img" />
<img src="img5.png" alt="img" />
</div>
</div>
</ReactLenis>
);
};
"use client";
import ReactLenis from '@studio-freight/react-lenis'
const page = () => {
return (
<ReactLenis root>
<div className="projects">
<div className="images">
<img src="img1.png" alt="img" />
<img src="img2.png" alt="img" />
<img src="img3.png" alt="img" />
<img src="img5.png" alt="img" />
</div>
</div>
</ReactLenis>
);
};
Step 3: Explaining the Code (With a Dash of Humor)
Now that we’ve got the code in place, let’s break it down like we’re explaining it to a friend over coffee. No jargon overload, just the juicy bits!
Global CSS: The Styling Superhero
The globals.css file is like the wardrobe department of our website. It makes everything look good and sets up the transition groundwork.
- Variables: We define --bg (dark background) and --copy (white text) to keep colors consistent. Think of these as your favorite paint swatches.
- Reset Styles: The * selector wipes out default margins and padding, ensuring our layout is a clean slate.
- Body and Fonts: We use a fancy font (Neue Haas Grotesk Display Pro) and set a dark background with white text. It’s like dressing our site in a tuxedo.
- Navigation: The .nav class creates a fixed header with links spaced out nicely. It’s like a GPS bar that’s always there.
- Home Page: The .home class centers a huge h1 (like a movie title) with a clip-path for transition readiness. The .char class prepares each letter for animation.
- Info Page: The .info class splits the page into two columns (image and text), with .line and span for animating text lines.
- View Transitions: The ::view-transition-* rules disable default animations and set z-index to ensure our custom transitions shine. It’s like telling the browser, “Step aside, I’ve got this!”
NavLink: The Transition Choreographer
The NavLink.jsx component is the star of our transition show. It’s like a dance instructor telling pages how to enter and exit.
- Transition Functions: We define four transitions (topToBottom, bottomToTop, leftToRight, rightToLeft). Each uses the Web Animations API to:
- Fade and slide the old page out (::view-transition-old(root)).
- Clip the new page in with a clipPath (::view-transition-new(root)).
- For example, topToBottom slides the old page down and reveals the new page from the top, like pulling a curtain.
- Router Magic: The useTransitionRouter hook from next-view-transitions lets us trigger these animations when navigating. The handleClick function says, “When clicked, run the transition and then go to the new page.”
- Link Component: We wrap Next.js’s Link component to make navigation smooth and animated.
Root Layout: The Stage Manager
The layout.jsx file is like the director who sets up the stage for every page.
- Fonts: We load Geist and Geist_Mono for fallback fonts and import Neue Haas Grotesk for that premium look.
- ViewTransitions: The <ViewTransitions> component enables view transitions across the app.
- Nav and Children: We include the Nav component and render the current page’s content (children).
Navigation: The Menu Board
The Nav.jsx component is our site’s menu, like a restaurant sign listing “Index,” “Projects,” and “Info.”
- NavLink Usage: Each link uses the NavLink component with a specific transitionType. For example, clicking “Projects” triggers a leftToRight transition, making the page slide in like a cool spy.
- Structure: The nav is split into a logo section (with the “Index” link) and a links section (for “Projects” and “Info”).
Home Page: The Grand Entrance
The homepage (page.jsx) is where we make a bold first impression.
- ReactLenis: This wraps the page for smooth scrolling, like butter on toast.
- GSAP Animation: We use SplitType to break the h1 text (“Mohit.”) into individual characters. GSAP then animates each character sliding up from y: 200 to y: 0, with a staggered effect for drama.
- Ref: The container ref scopes the GSAP animation to the .home div, keeping things tidy.
Info Page: The Storyteller
The info page (info/page.jsx) shows an image and some text, with a fancy entrance.
- Layout: Two columns (col)—one for an image, one for text. The text column is centered with padding.
- GSAP Animation: SplitType splits the paragraph into lines, wrapping each in a span. GSAP slides each line up from y: 200 to y: 0, staggered for a wave-like effect.
- Cleanup: The revert method in the cleanup function ensures SplitType doesn’t mess up the DOM when the component unmounts.
Projects Page: The Gallery
The projects page (projects/page.jsx) is super simple—just a stack of images.
- ReactLenis: Again, smooth scrolling for that premium feel.
- Images: A div with class images holds a list of <img> tags. You’ll need to replace the placeholder img1.png etc., with real images.
Step 4: Running and Testing
To see your masterpiece in action:
- Place images (e.g., img1.png, img2.png) in the public folder.
- Run npm run dev.
- Click the nav links to watch pages slide in and out like a Hollywood movie.
If transitions don’t work, double-check:
- All dependencies are installed.
- The NavLink component is used correctly.
- CSS clip-path and z-index rules are intact.
Step 5: Why This is Awesome
This setup is beginner-friendly because:
- No Heavy Frameworks: Just CSS and a bit of JS, keeping things light.
- Reusable: The NavLink component can be used anywhere.
- Customizable: Tweak the transitionFunctions to create your own animations (maybe a zoom or spin?).
- Modern: Uses Next.js’s App Router and view transitions API, making you look like a cutting-edge dev.
Final Thoughts
And there you have it — a portfolio site with smooth page transitions that’ll make your friends jealous! You’ve learned how to set up a Next.js project, style it with CSS, animate text with GSAP, and create custom transitions with next-view-transitions. Now go forth and experiment—maybe add a diagonal slide or a fade-to-black effect. The web is your playground!
Got questions? Drop them in the comments, and I’ll help you debug faster than you can say “CSS is awesome.” Happy coding! 🚀