Implementing View Transitions in Next.js App Router: A Step-by-Step Guide

Fri Oct 11 2024

Overview

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 or pagereveal 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:

  1. Extended Router: A custom hook that wraps the Next.js router to support view transitions.
  2. View Transitions Context: A React context to manage the completion of view transitions.
  3. Browser Native Transitions Hook: Handles browser-level transitions and synchronizes route changes with view transitions.
  4. Hash Management Hook: Manages hash changes in the URL to detect page updates.
  5. 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.

References