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";
2
import { useState } from "react";
3
import CardItem from "./CardItem";
4
import Modal from "./Modal";
5
6
const 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
];
13
14
type Color = (typeof colors)[0];
15
16
export default function App() {
17
const [selectedItem, setSelectedItem] = useState<Color | null>(null);
18
19
return (
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
<CardItem
24
key={item.id}
25
color={item.color}
26
href={`/page-transition/${item.id}`}
27
onClick={() => setSelectedItem(item)}
28
/>
29
))}
30
</div>
31
32
<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
<div
35
className="max-w-[600px] w-full aspect-[4/3] rounded-lg"
36
style={{ background: selectedItem?.color }}
37
/>
38
<a
39
href="/page-transition"
40
onClick={(e) => {
41
if (!e.metaKey && !e.ctrlKey) {
42
e.preventDefault();
43
setSelectedItem(null);
44
}
45
}}
46
className="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
>
48
Home
49
</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

1
interface CardItemProps {
2
color: string;
3
onClick: () => void;
4
href: string;
5
}
6
7
export default function CardItem({ color, onClick, href }: CardItemProps) {
8
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
9
if (!e.metaKey && !e.ctrlKey) {
10
e.preventDefault();
11
onClick();
12
}
13
};
14
15
return (
16
<a
17
href={href}
18
onClick={handleClick}
19
style={{ background: color }}
20
className="w-full aspect-[4/3] rounded-lg block"
21
/>
22
);
23
}

The Modal handles accessibility, focus management, and uses createPortal to render outside the normal DOM hierarchy.

Modal.tsx

1
import { useEffect, useRef } from "react";
2
import { createPortal } from "react-dom";
3
4
interface ModalProps {
5
isOpen: boolean;
6
onClose: () => void;
7
children: React.ReactNode;
8
}
9
10
export default function Modal({ isOpen, onClose, children }: ModalProps) {
11
const modalRef = useRef<HTMLDivElement>(null);
12
const previousActiveElement = useRef<HTMLElement | null>(null);
13
14
useEffect(() => {
15
if (isOpen) {
16
// Store currently focused element
17
previousActiveElement.current = document.activeElement as HTMLElement;
18
19
// Focus the modal
20
modalRef.current?.focus();
21
22
// Prevent body scroll
23
document.body.style.overflow = "hidden";
24
} else {
25
// Restore body scroll
26
document.body.style.overflow = "";
27
28
// Restore focus to previous element
29
previousActiveElement.current?.focus();
30
}
31
32
return () => {
33
document.body.style.overflow = "";
34
};
35
}, [isOpen]);
36
37
if (!isOpen) return null;
38
39
const modalContent = (
40
<div
41
ref={modalRef}
42
className="fixed inset-0 z-50 bg-white"
43
role="dialog"
44
aria-modal="true"
45
aria-labelledby="modal-title"
46
tabIndex={-1}
47
>
48
{children}
49
</div>
50
);
51
52
return 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 2: Shared Layout Animation

This is where Motion's layoutId comes in. By giving both the card and the modal content the same layoutId, Motion automatically animates between them.

page.tsx {1,7,14,16-22,25}

1
import { motion } from "motion/react";
2
3
interface CardItemProps {
4
color: string;
5
onClick: () => void;
6
layoutId: string;
7
href: string;
8
}
9
10
export function CardItem({ color, onClick, layoutId, href }: CardItemProps) {
11
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
12
if (!e.metaKey && !e.ctrlKey) {
13
e.preventDefault();
14
onClick();
15
}
16
};
17
18
return (
19
<motion.a
20
href={href}
21
onClick={handleClick}
22
layoutId={layoutId}
23
style={{ background: color }}
24
className="w-full aspect-[4/3] rounded-lg block"
25
/>
26
);
27
}
28
29
export function Content({ selectedItem, handleGoBack }) {
30
return (
31
<div className="h-full w-full flex flex-col items-center justify-center gap-6 p-4">
32
<motion.div
33
layoutId={selectedItem?.color}
34
style={{ background: selectedItem?.color }}
35
className="max-w-[600px] w-full aspect-[4/3] rounded-lg"
36
transition={{
37
type: "spring",
38
stiffness: 250,
39
damping: 30,
40
}}
41
/>
42
<a
43
href="/page-transition"
44
onClick={(e) => {
45
if (!e.metaKey && !e.ctrlKey) {
46
e.preventDefault();
47
handleGoBack();
48
}
49
}}
50
className="bg-[#F1F1F1] rounded-[3.125rem] py-2 px-6 text-sm font-medium text-[#C3C3C5] cursor-pointer no-underline"
51
>
52
Home
53
</a>
54
</div>
55
);
56
}

The key insight here: both elements share the same layoutId. When one unmounts and the other mounts, Motion creates a smooth transition between them. It's like they're the same element transforming in place.

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}

1
const handleClick = (data) => {
2
// Update state to trigger animation
3
setSelectedItem(data);
4
5
// Update URL without navigation
6
window.history.pushState({ id: data.id }, "", `/page-transition/${data.id}`);
7
};
8
9
const handleClose = () => {
10
setSelectedItem(null);
11
window.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 4: Browser Navigation Support

Users expect the back button to work. We need to listen for popstate events and update our state accordingly.

page.tsx

1
useEffect(() => {
2
const handlePopState = () => {
3
if (window.location.pathname === "/page-transition") {
4
setSelectedItem(null);
5
} else {
6
const slug = window.location.pathname.split("/page-transition/")[1];
7
const parsed = parseInt(slug);
8
const found = colors.find((color) => color.id === parsed);
9
if (found) {
10
setSelectedItem(found);
11
}
12
}
13
};
14
15
window.addEventListener("popstate", handlePopState);
16
return () => window.removeEventListener("popstate", handlePopState);
17
}, []);

Now when users hit back, the modal smoothly animates closed and they return to the grid. Forward button? Works too.

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";
2
import { colors, Content } from "../page";
3
import { redirect, useParams, useRouter } from "next/navigation";
4
5
export default function Page() {
6
const params = useParams();
7
const router = useRouter();
8
const selectedItem = colors.find((color) => color.id === Number(params.id));
9
10
if (!selectedItem) {
11
redirect("/page-transition");
12
}
13
14
return (
15
<div className="h-screen w-full flex justify-center items-center">
16
<Content
17
selectedItem={selectedItem}
18
handleGoBack={() => router.push("/page-transition")}
19
/>
20
</div>
21
);
22
}

This route does three important things:

  1. Finds the matching item - Uses the id param to look up the corresponding color from your shared data
  2. Handles invalid IDs - Redirects to the grid if the ID doesn't exist
  3. 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}

1
export function CardItem({
2
color,
3
onClick,
4
layoutId,
5
href,
6
isItemSelected = false,
7
}: CardItemProps) {
8
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
9
if (!e.metaKey && !e.ctrlKey) {
10
e.preventDefault();
11
onClick();
12
}
13
};
14
15
return (
16
<motion.a
17
href={href}
18
onClick={handleClick}
19
layoutId={layoutId}
20
animate={{
21
opacity: isItemSelected ? 0 : 1,
22
}}
23
transition={{
24
type: "spring",
25
stiffness: 250,
26
damping: 30,
27
opacity: {
28
duration: 0.1,
29
delay: isItemSelected ? 0 : 0.225,
30
},
31
}}
32
style={{ background: color }}
33
className="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

1
transition={{
2
type: "spring",
3
stiffness: 250,
4
damping: 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 pushState for instant updates
  • Browser back/forward button support with popstate listener
  • 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.

Building Smooth Page Transitions in React | RNDR Realm