2025/04/25

Using View Transitions in Next.js

reactNext.jsView Transition API

With the View Transitions API having more widespread support across browsers, and React adding an experimental flag (as of 23rd April 2025) and component for the feature, I decided to play around with setting up transitions. This can provide those lovely hero image fades, and more, so we'll quickly talk about how you can set up using the View Transitions component and setting it up for your own animations.

Enabling Transitions in Next.js

If you're using Next.js, you'll need to enable View Transitions with the experimental flag, you can do this in our config file.

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    viewTransition: true,
  },
}
 
module.exports = nextConfig

Structuring our code

To get view transitions working, we need to get some base code set up. I've put together a few example components that show how you could structure your site, whilst taking into account the fact you may want to use loops and unknown amounts of elements.

The below code presumes you are running the following:

  • Next.js v15.3+
  • Using the app router for folder structure.

Layout (app/layout.tsx)

import { unstable_ViewTransition as ViewTransition } from "react";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <ViewTransition>{children}</ViewTransition>
      </body>
    </html>
  );
}

Home Page (app/page.tsx)

import ArticleCard from "@/components/article-card";

export default function Home() {
  return (
    <div className="p-10 grid grid-cols-3 gap-10">
      <ArticleCard id={1} />
      <ArticleCard id={2} />
      <ArticleCard id={3} />
      <ArticleCard id={4} />  
      ....
      <ArticleCard id={210} />
    </div>
  );
}

Card Component (components/article-card.tsx)

import Image from "next/image";
import Link from "next/link";

export default function ArticleCard({ id }: { id: number }) {
  const imageStyle = { viewTransitionName: `article-image-${index}` };

  return (
    <Link
      href={`/article/${id}`}
      className="article-card block bg-gray-100 mb-4"
    >
      <Image
        src="/image.jpg"
        width="600"
        height="300"
        alt="Image of a beautiful dog"
        className="img w-full block"
        style={imageStyle}
      />

      <h3 className="p-4">
        Example Blog Title {id}
      </h3>
    </Link>
  );
}

Single Article Page (app/article/[id]/page.tsx)

import Image from "next/image";
import Link from "next/link";

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const imageStyle = { viewTransitionName: `article-image-${id}` };

  return (
    <article className="p-10 bg-red-100 h-screen">
      <Image
        src="/image.jpg"
        width="1200"
        height="900"
        alt="Image of a beautiful dog"
        style={imageStyle}
      />

      <h1 className="text-3xl" style={headingStyle}>
        Example Blog Title {id}
      </h1>
      
      <Link className="mb-4 block" href="/">
        Go Back
      </Link>
    </article>
  );
}

The structure of our example is a list of articles, each with a unique ID. These then link to a single page with a URL parameter of the same ID. Within the code, there are a few key parts to take away about why we've set it up the way we have, and what's important:

  • In the article-card component, we are setting a view-transition-name property on the image. This is so the Transition API can identify what is going to transition into what. As if we are using an unknown number of elements, we are setting a unique identifier based off the ID of the article.
  • In the article page, we're also setting a view-transition-name however this time, we're using the query param to get the ID so that it matches up with our article-card.
  • If you were to add hundreds of cards, each one would animate to it's corresponding image thanks to the dynamic view-transition-name. Meaning, once set up, you don't need to add a new one to your CSS file for each new item.

Getting Fancy (with CSS)

If you want to take your transitions one step further, you can set up styles on how you want the browser to animate the content. 

Changing a single animation

You can target a single animation, such as the 10th article image, by targeting the specific transition name, e.g. article-image-10.

::view-transition-old(article-image-10) {
  animation: 300ms ease-out fade-out;
}

::view-transition-new(article-image-10) {
  animation: 300ms ease-in fade-in;
}

Changing the animation for all elements with one class

If you want to target everything in our loop, we can target this by setting a view transition class and then targeting the elements on that.

.article-card img {
    view-transition-class: article-image;
}

article img {
    view-transition-class: article-image;
}

@keyframes animate-in {
    0% {
        opacity: 0
    }

    100% {
        opacity: 1
    }
}

::view-transition-group(*.article-image) {
    animation: animate-in ease-in 300ms;
}

In the above code, we're utilising the view-transition-group property so we can target all elements with a view-transition-class of article-image. Rather than writing a few lines of CSS for every image, we tackle them all at once, keeping our CSS code nice and neat. Although the above only animates opacity, I'm sure you can get much more imaginative.

Note: although we write the view-transition-class here, we still need to ensure we keep the view-transition-name on each object. This is so the browser knows how to identify them.