Implementing View Transitions in Next.js App Router: A Step-by-Step Guide
Fri Oct 11 2024Overview
This guide provides a step-by-step approach to implementing view transitions in a Next.js application using the App Router. By integrating the View Transitions API
, you can enhance user experience with smooth animations during page navigation. We'll address the challenges and walk through the solution in detail.
Prerequisites
Before you begin, ensure you have the following:
- Basic understanding of Next.js and React.
- Experience with modern JavaScript and TypeScript.
- A Next.js application set up and running locally.
Introduction
The View Transitions API allows developers to create animated transitions between different views in a web application. While it works seamlessly with traditional Multi-Page Applications (MPAs), integrating it with Next.js's App Router presents unique challenges:
- The Next.js router operates more like a Single Page Application (SPA) router.
- It doesn't emit
pageswap
orpagereveal
events on route changes. - Managing view transitions requires careful handling of navigation and state updates.
Our goal is to overcome these challenges and implement smooth view transitions in a Next.js application using the App Router.
Solution Overview
To integrate the View Transitions API with Next.js, we'll implement the following components:
- Extended Router: A custom hook that wraps the Next.js router to support view transitions.
- View Transitions Context: A React context to manage the completion of view transitions.
- Browser Native Transitions Hook: Handles browser-level transitions and synchronizes route changes with view transitions.
- Hash Management Hook: Manages hash changes in the URL to detect page updates.
- Custom Link Component: Replaces the default Next.js
Link
component to use our extended router.
Step-by-Step Implementation
1. Extending the Next.js Router
We need to wrap the Next.js router's push
and replace
methods with document.startViewTransition
to initiate view transitions during navigation.
// useTransitionRouter.ts
import { useRouter as useNextRouter } from "next/navigation";
import { startTransition, useCallback, useMemo } from "react";
import {
AppRouterInstance,
NavigateOptions,
} from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useSetFinishViewTransition } from "@/contexts/ViewTransitionsContext/ViewTransitionsContext";
export type TransitionOptions = {
onTransitionReady?: () => void;
};
type NavigateOptionsWithTransition = NavigateOptions & TransitionOptions;
export type TransitionRouter = AppRouterInstance & {
push: (href: string, options?: NavigateOptionsWithTransition) => void;
replace: (href: string, options?: NavigateOptionsWithTransition) => void;
};
export function useTransitionRouter() {
const router = useNextRouter();
const finishViewTransition = useSetFinishViewTransition();
const triggerTransition = useCallback(
(cb: () => void, { onTransitionReady }: TransitionOptions = {}) => {
if ("startViewTransition" in document) {
// @ts-ignore
const transition = document.startViewTransition(
() =>
new Promise<void>((resolve) => {
startTransition(() => {
cb();
finishViewTransition(() => resolve());
});
})
);
if (onTransitionReady) {
transition.ready.then(onTransitionReady);
}
} else {
return cb();
}
},
[finishViewTransition]
);
const push = useCallback(
(
href: string,
{ onTransitionReady, ...options }: NavigateOptionsWithTransition = {}
) => {
triggerTransition(() => router.push(href, options), {
onTransitionReady,
});
},
[triggerTransition, router]
);
const replace = useCallback(
(
href: string,
{ onTransitionReady, ...options }: NavigateOptionsWithTransition = {}
) => {
triggerTransition(() => router.replace(href, options), {
onTransitionReady,
});
},
[triggerTransition, router]
);
return useMemo<TransitionRouter>(
() => ({
...router,
push,
replace,
}),
[push, replace, router]
);
}
This hook extends the Next.js router by wrapping navigation methods with view transitions, allowing us to initiate animations during route changes.
2. Creating a View Transitions Context
We need a context to manage the completion of view transitions and coordinate with the router's navigation.
// ViewTransitionsContext.tsx
"use client";
import type { Dispatch, SetStateAction } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { useBrowserNativeTransitions } from "@/hooks/useBrowserNativeTransitions/useBrowserNativeTransitions";
const ViewTransitionsContext = createContext<
Dispatch<SetStateAction<(() => void) | null>>
>(() => () => {});
export function ViewTransitions({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [finishViewTransition, setFinishViewTransition] = useState<
null | (() => void)
>(null);
useEffect(() => {
if (finishViewTransition) {
finishViewTransition();
setFinishViewTransition(null);
}
}, [finishViewTransition]);
useBrowserNativeTransitions();
return (
<ViewTransitionsContext.Provider value={setFinishViewTransition}>
{children}
</ViewTransitionsContext.Provider>
);
}
export function useSetFinishViewTransition() {
return useContext(ViewTransitionsContext);
}
This context provides a way to signal when a view transition should finish, ensuring state updates and DOM changes occur within the transition.
3. Handling Browser Native Transitions
We handle browser-level transitions, especially for back and forward navigations, to synchronize route changes with view transitions.
// useBrowserNativeTransitions.ts
import { useEffect, useRef, useState, use } from "react";
import { usePathname } from "next/navigation";
import { useHash } from "../useHash/useHash";
export function useBrowserNativeTransitions() {
const pathname = usePathname();
const currentPathname = useRef(pathname);
const [currentViewTransition, setCurrentViewTransition] = useState<
| null
| [
Promise<void>,
() => void,
]
>(null);
useEffect(() => {
if (!("startViewTransition" in document)) {
return () => {};
}
const onPopState = () => {
let pendingViewTransitionResolve: () => void;
const pendingViewTransition = new Promise<void>((resolve) => {
pendingViewTransitionResolve = resolve;
});
// @ts-ignore
const pendingStartViewTransition = new Promise<void>((resolve) => {
// @ts-ignore
document.startViewTransition(() => {
resolve();
return pendingViewTransition;
});
});
setCurrentViewTransition([
pendingStartViewTransition,
pendingViewTransitionResolve!,
]);
};
window.addEventListener("popstate", onPopState);
return () => {
window.removeEventListener("popstate", onPopState);
};
}, []);
if (currentViewTransition && currentPathname.current !== pathname) {
// Block rendering of the new route until the view transition starts
use(currentViewTransition[0]);
}
// Keep the transition reference up-to-date.
const transitionRef = useRef(currentViewTransition);
useEffect(() => {
transitionRef.current = currentViewTransition;
}, [currentViewTransition]);
const hash = useHash();
useEffect(() => {
// When the new route component is mounted, finish the view transition.
currentPathname.current = pathname;
if (transitionRef.current) {
transitionRef.current[1]();
transitionRef.current = null;
}
}, [hash, pathname]);
}
This hook listens for popstate
events and ensures that view transitions are initiated and completed appropriately during browser navigation.
4. Managing Hash Changes
We use the URL hash to detect changes in the page and ensure that the view transition finishes when the new content is rendered.
// useHash.ts
import { useSyncExternalStore } from "react";
export function useHash() {
return useSyncExternalStore(
subscribeHash,
getHashSnapshot,
getServerHashSnapshot
);
}
function getHashSnapshot() {
return window.location.hash;
}
function getServerHashSnapshot() {
return "";
}
function subscribeHash(onStoreChange: () => void) {
window.addEventListener("hashchange", onStoreChange);
return () => window.removeEventListener("hashchange", onStoreChange);
}
This hook subscribes to hash changes in the URL, helping us detect when the page has been updated.
5. Updating the Custom Link Component
We replace Next.js's Link
component with our own that uses the extended router and handles navigation appropriately for view transitions.
// ViewTransitionLink.tsx
"use client";
import { useTransitionRouter } from "@/hooks/useTransitionRouter/useTransitionRouter";
import React, { ReactNode } from "react";
interface Props {
href: string;
children: ReactNode;
}
export const ViewTransitionLink = ({ href, children }: Props) => {
const router = useTransitionRouter();
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
// Start the view transition before navigation
if (document.startViewTransition) {
document.startViewTransition(() => {
router.push(href);
});
} else {
// Fallback for browsers that do not support the View Transitions API
router.push(href);
}
};
return (
<a href={href} onClick={handleClick}>
{children}
</a>
);
};
This component ensures that navigation triggers view transitions by using our extended router and handling click events appropriately.
Putting It All Together
Now that we've implemented the necessary components, let's integrate them into our Next.js application.
1. Wrap Your Application with the ViewTransitions
Provider
Ensure your app is wrapped with the context provider to manage transitions.
// In your root component or _app.tsx
import { ViewTransitions } from "@/contexts/ViewTransitionsContext/ViewTransitionsContext";
function MyApp({ Component, pageProps }) {
return (
<ViewTransitions>
<Component {...pageProps} />
</ViewTransitions>
);
}
export default MyApp;
2. Use the Custom Link Component
Replace instances of Next.js's Link
with your custom ViewTransitionLink
.
// In your component
import { ViewTransitionLink } from "@/components/ViewTransitionLink/ViewTransitionLink";
<ViewTransitionLink href={`/detail?id=${item.id}`}>
<div
key={item.id}
data-item={item.id}
className="item"
>
{/* Your content */}
</div>
</ViewTransitionLink>
3. Set viewTransitionName
on Elements
Before navigation, set viewTransitionName
on the elements you want to animate.
// In your event handler
const handleClick = (itemId) => {
const element = document.querySelector(
`[data-item="${itemId}"] .color-box`
) as HTMLElement;
if (element) {
element.style.viewTransitionName = `item-${itemId}`;
}
};
4. Test Transitions
Ensure that transitions occur smoothly when navigating between pages. Check that state updates and DOM changes are synchronized with the transitions.
Conclusion
Integrating the View Transitions API with Next.js's App Router enhances user experience by providing smooth animations during navigation. By extending the router, managing transition state with context, and handling browser native transitions, we can achieve seamless view transitions between pages.
Key Takeaways:
- Custom Router Extension: Wrapping navigation methods allows us to initiate view transitions during client-side navigations.
- Context Management: Using React context helps coordinate the transition lifecycle.
- Browser Transitions Handling: Managing
popstate
events and hash changes ensures transitions work with browser navigation. - Custom Link Component: Replacing the default
Link
component ensures that our extended router is used.
Additional Considerations
- Browser Compatibility: The View Transitions API is currently only supported in Chromium-based browsers (e.g., Chrome 111+). Provide fallbacks or handle unsupported browsers gracefully.
- Performance Implications: Be mindful of potential performance impacts, especially with large or complex DOM structures.
- Testing: Thoroughly test the implementation across different browsers and devices.
- Future Updates: Keep an eye on updates to Next.js and React, as future versions may provide better integration with the View Transitions API.