rndr realm
Feb 15, 2026
Next.js Page Transitions
Page transitions can make the difference between a good user experience and a great one. When done right, they create a sense of continuity and help users understand the relationship between different views.
But here's the challenge: implementing smooth transitions between routes in Next.js isn't straightforward. Traditional client-side routing triggers full component unmounts, breaking the animation chain before it even starts. You end up with jarring jumps instead of smooth morphs.
In this article, I'll show you how to build a page transition system that actually works. We'll create a setup where clicking a card smoothly expands it into a full-screen detail view, complete with proper URL changes, browser history support, and shareable links. The trick? Don't use Next.js routing for the animation itself.
Instead, we'll use window.history.pushState to update the URL imperatively while keeping our components mounted, then add a fallback route for direct links and refreshes. This gives us the best of both worlds: buttery smooth animations that feel native, plus all the benefits of proper routing.
The whole thing is built with React, Motion's layout animations, and some clever state management. Let's break it down step by step.
Fair warning: This approach is unconventional. We're deliberately bypassing Next.js's routing system to keep components mounted during transitions. It works beautifully for animations, but it means you're working around the framework's opinions. If you need server-side rendering for every route change or rely heavily on Next.js's data fetching patterns, this might not be the right solution.
Alternative: The View Transitions API offers a native browser solution for page transitions, though browser support is still evolving and it requires different integration patterns with Next.js.
But if smooth, app-like transitions with full control are your priority? This approach delivers.
Step 1: The Basic Structure
Before any animation magic, we need the core structure: a grid of cards and a modal that displays the selected card in detail.
import { useState } from "react"; import { createPortal } from "react-dom"; import CardItem from "./CardItem"; import Modal from "./Modal"; import "./styles.css"; const colors = [ { id: 1, color: "oklch(0.982 0.015 295.0)" }, { id: 2, color: "oklch(0.942 0.054 295.0)" }, { id: 3, color: "oklch(0.886 0.091 295.0)" }, { id: 4, color: "oklch(0.780 0.141 295.0)" }, { id: 6, color: "oklch(0.594 0.191 295.0)" }, ]; export default function App() { const [selectedItem, setSelectedItem] = useState(null); return ( <> <div className="grid"> {colors.map((item) => ( <CardItem key={item.id} color={item.color} href={`/page-transition/${item.id}`} onClick={() => setSelectedItem(item)} /> ))} </div> <Modal isOpen={!!selectedItem} onClose={() => setSelectedItem(null)}> <div className="content-wrapper"> <div className="modal-content" style={{ background: selectedItem?.color }} /> <a href="/page-transition" className="back-button" onClick={(e) => { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setSelectedItem(null); } }} > Home </a> </div> </Modal> </> ); }
Let's break down each component:
App Component
The main component manages state and renders the grid of cards plus the modal.
App.tsx
1"use client";2import { useState } from "react";3import CardItem from "./CardItem";4import Modal from "./Modal";56const colors = [7{ id: 1, color: "oklch(0.982 0.015 295.0)" },8{ id: 2, color: "oklch(0.942 0.054 295.0)" },9{ id: 3, color: "oklch(0.886 0.091 295.0)" },10{ id: 4, color: "oklch(0.780 0.141 295.0)" },11{ id: 6, color: "oklch(0.594 0.191 295.0)" },12];1314type Color = (typeof colors)[0];1516export default function App() {17const [selectedItem, setSelectedItem] = useState<Color | null>(null);1819return (20<>21<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">22{colors.map((item) => (23<CardItem24key={item.id}25color={item.color}26href={`/page-transition/${item.id}`}27onClick={() => setSelectedItem(item)}28/>29))}30</div>3132<Modal isOpen={!!selectedItem} onClose={() => setSelectedItem(null)}>33<div className="h-full w-full flex flex-col items-center justify-center gap-6 p-4">34<div35className="max-w-[600px] w-full aspect-[4/3] rounded-lg"36style={{ background: selectedItem?.color }}37/>38<a39href="/page-transition"40onClick={(e) => {41if (!e.metaKey && !e.ctrlKey) {42e.preventDefault();43setSelectedItem(null);44}45}}46className="bg-[#F1F1F1] rounded-[3.125rem] py-2 px-6 text-sm font-medium text-[#C3C3C5] cursor-pointer leading-[1.25rem] tracking-[-0.0056em] no-underline"47>48Home49</a>50</div>51</Modal>52</>53);54}
CardItem Component
We use an anchor element instead of a button for better accessibility and semantics. The cards represent navigation to different pages, so links are more appropriate. We prevent the default navigation behavior (except when Cmd/Ctrl-clicking) to trigger our custom animation instead.
CardItem.tsx
1interface CardItemProps {2color: string;3onClick: () => void;4href: string;5}67export default function CardItem({ color, onClick, href }: CardItemProps) {8const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {9if (!e.metaKey && !e.ctrlKey) {10e.preventDefault();11onClick();12}13};1415return (16<a17href={href}18onClick={handleClick}19style={{ background: color }}20className="w-full aspect-[4/3] rounded-lg block"21/>22);23}
Modal Component
The Modal handles accessibility, focus management, and uses createPortal to render outside the normal DOM hierarchy.
Modal.tsx
1import { useEffect, useRef } from "react";2import { createPortal } from "react-dom";34interface ModalProps {5isOpen: boolean;6onClose: () => void;7children: React.ReactNode;8}910export default function Modal({ isOpen, onClose, children }: ModalProps) {11const modalRef = useRef<HTMLDivElement>(null);12const previousActiveElement = useRef<HTMLElement | null>(null);1314useEffect(() => {15if (isOpen) {16// Store currently focused element17previousActiveElement.current = document.activeElement as HTMLElement;1819// Focus the modal20modalRef.current?.focus();2122// Prevent body scroll23document.body.style.overflow = "hidden";24} else {25// Restore body scroll26document.body.style.overflow = "";2728// Restore focus to previous element29previousActiveElement.current?.focus();30}3132return () => {33document.body.style.overflow = "";34};35}, [isOpen]);3637if (!isOpen) return null;3839const modalContent = (40<div41ref={modalRef}42className="fixed inset-0 z-50 bg-white"43role="dialog"44aria-modal="true"45aria-labelledby="modal-title"46tabIndex={-1}47>48{children}49</div>50);5152return createPortal(modalContent, document.body);53}
Nothing fancy yet. Just a grid of colored cards that open a modal when clicked. The Modal component handles all the accessibility features: focus management, scroll prevention, and proper ARIA attributes. Inside the modal, we display the selected card in a larger size with a back button to close it. The real magic happens when we add the layout animation.
Step 3: URL Integration
For a proper page transition, we need to sync the state with the URL. This way, users can share links, use back/forward buttons, and the experience feels like actual navigation.
page.tsx {6-10,15}
1const handleClick = (data) => {2// Update state to trigger animation3setSelectedItem(data);45// Update URL without navigation6window.history.pushState({ id: data.id }, "", `/page-transition/${data.id}`);7};89const handleClose = () => {10setSelectedItem(null);11window.history.pushState({}, "", `/page-transition`);12};
We use pushState instead of Next.js routing because we want the animation to happen immediately without any page reload or navigation delay. This keeps the animation buttery smooth.
Step 5: Adding a Fallback Route
When users refresh the page or land directly on a detail URL like /page-transition/2, they shouldn't see a 404 error. Instead, we need a fallback route that displays the detail view exactly like the modal would.
Create a dynamic route at /page-transition/[id]/page.tsx:
1"use client";2import { colors, Content } from "../page";3import { redirect, useParams, useRouter } from "next/navigation";45export default function Page() {6const params = useParams();7const router = useRouter();8const selectedItem = colors.find((color) => color.id === Number(params.id));910if (!selectedItem) {11redirect("/page-transition");12}1314return (15<div className="h-screen w-full flex justify-center items-center">16<Content17selectedItem={selectedItem}18handleGoBack={() => router.push("/page-transition")}19/>20</div>21);22}
This route does three important things:
- Finds the matching item - Uses the
idparam to look up the corresponding color from your shared data - Handles invalid IDs - Redirects to the grid if the ID doesn't exist
- Reuses the Content component - Shows exactly the same layout as the modal, maintaining visual consistency
Now when users:
- Refresh the page on
/page-transition/2→ They see the detail view - Share a direct link → Recipients see the full detail page
- Click the back button → They navigate to the grid using Next.js routing
The key difference: navigating from this page to the grid won't have the smooth layout animation since there's no shared element on mount. But that's expected behavior. Animations work best for in-app transitions, not initial page loads.
Step 6: Polishing the Grid
When a card is selected, we want all the other cards to fade out subtly. This helps focus attention on the animating element.
page.tsx {5-11,14-15,17-25}
1export function CardItem({2color,3onClick,4layoutId,5href,6isItemSelected = false,7}: CardItemProps) {8const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {9if (!e.metaKey && !e.ctrlKey) {10e.preventDefault();11onClick();12}13};1415return (16<motion.a17href={href}18onClick={handleClick}19layoutId={layoutId}20animate={{21opacity: isItemSelected ? 0 : 1,22}}23transition={{24type: "spring",25stiffness: 250,26damping: 30,27opacity: {28duration: 0.1,29delay: isItemSelected ? 0 : 0.225,30},31}}32style={{ background: color }}33className="w-full aspect-[4/3] rounded-lg block"34/>35);36}
The key detail: when isItemSelected becomes false (user closes modal), we add a delay to the opacity animation. This ensures the layout animation completes before the other cards fade back in. Without this delay, you'd see the cards pop back in while the element is still animating.
Step 7: Tuning the Spring
Motion's spring animations are powerful, but they need tuning. The default spring might feel too bouncy or too slow.
page.tsx
1transition={{2type: "spring",3stiffness: 250,4damping: 30,5}}
- stiffness: 250 - Controls how "tight" the spring is. Higher values make it snap faster.
- damping: 30 - Controls how much bounce. Higher values reduce oscillation.
These values create a snappy, responsive feel without being jarring. Experiment with them to find what feels right for your project.
The Final Result
Here's what we built: a complete page transition system that feels native.
Core Animation
- Shared element transitions using Motion's
layoutId - Tuned spring physics for snappy, responsive feel
- Subtle opacity transitions to guide user focus
Routing & Navigation
- URL state management with
pushStatefor instant updates - Browser back/forward button support with
popstatelistener - Fallback route that handles direct links and page refreshes
- Shareable URLs that actually work
The beauty of this approach is that it's entirely declarative. You're not manually calculating positions or managing complex animation states. Motion's layout animations handle all the heavy lifting.
Try It Yourself
Here's the complete implementation with all the features we've covered. Click on any card to see the transition, and notice how the URL changes. You can even use the browser's back button to navigate!
import { useState, useEffect } from "react"; import CardItem from "./CardItem"; import Modal from "./Modal"; import Content from "./Content"; import "./styles.css"; const colors = [ { id: 1, color: "oklch(0.982 0.015 295.0)" }, { id: 2, color: "oklch(0.942 0.054 295.0)" }, { id: 3, color: "oklch(0.886 0.091 295.0)" }, { id: 4, color: "oklch(0.780 0.141 295.0)" }, { id: 6, color: "oklch(0.594 0.191 295.0)" }, ]; export default function App() { const [selectedItem, setSelectedItem] = useState(null); const handleClick = (data) => { setSelectedItem(data); window.history.pushState( { id: data.id }, "", `/page-transition/${data.id}` ); }; const handleClose = () => { setSelectedItem(null); window.history.pushState({}, "", "/page-transition"); }; useEffect(() => { const handlePopState = () => { if (window.location.pathname === "/page-transition") { setSelectedItem(null); } else { const slug = window.location.pathname.split("/page-transition/")[1]; const parsed = parseInt(slug); const found = colors.find((color) => color.id === parsed); if (found) { setSelectedItem(found); } } }; window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, []); return ( <div className="app"> <div className="grid"> {colors.map((item) => ( <CardItem key={item.id} color={item.color} href={`/page-transition/${item.id}`} onClick={() => handleClick(item)} layoutId={item.color} isItemSelected={ selectedItem !== null && selectedItem.id !== item.id } /> ))} </div> <Modal isOpen={!!selectedItem} onClose={handleClose}> <Content selectedItem={selectedItem} handleGoBack={handleClose} /> </Modal> </div> ); }
This preview includes:
- Shared layout animations with
layoutId - URL state management with
pushState - Browser back/forward button support
- Opacity transitions for non-selected cards
- Tuned spring animations
- Proper z-index stacking
See It In Production
Want to see this pattern in action on a real site? Check out rndrealm.com where we use this exact technique for smooth transitions between pages.
The same principles apply: shared layout IDs, imperative URL updates, and fallback routes creating transitions that feel cohesive and intentional.
Wrapping Up
Page transitions don't have to be complicated. The secret to smooth, app-like transitions isn't just the animation. It's how you manage your routes. By using pushState instead of full navigation, we keep animations buttery smooth while still maintaining proper URL state for sharing and browser history.
The key insight: decouple your animations from your routing. Let Motion handle the visual transitions with layoutId, while you control the URL imperatively. Then add a fallback route to catch direct links and refreshes. This three-part system (animated state changes, URL manipulation, and server-side fallbacks) creates an experience that feels native while staying true to the web.
The pattern I showed here works for any list-to-detail transition: product grids, photo galleries, blog post previews, you name it. Just swap out the colored cards for your actual content, set up your dynamic route, and you're good to go.
Try it yourself, experiment with the timing, and see what feels right for your project.