← Back home

A title that travels: View Transitions in Next.js 16

If you clicked into this post from the home page, you've already seen everything this article is about: the title you clicked didn't get swapped out with the rest of the page — it travelled, sliding and resizing itself into the article header above.

I didn't invent this. I took it from someone else's blog.

The blog I couldn't stop clicking#

One evening I caught myself doing something silly on blog.kalan.dev: opening a post, going back, opening another one — not to read, just to feel the navigation. The post title slid from the list into the article header like it was one continuous thing, and everything else quietly crossfaded around it.

I wanted it. So I opened DevTools and started asking questions.

Question 1: is this an animation library, or the native View Transitions API? Cheap way to find out — wrap the API before clicking anything:

const original = document.startViewTransition.bind(document);
document.startViewTransition = (...args) => {
  console.log("native view transition!");
  return original(...args);
};

The log fired on every navigation. Native API, no library — just the browser.

Question 2: why does the title morph while everything else fades? Inspecting both pages gave it away: the home page's h3 and the article page's h1 carry the same view-transition-name, something like post-title-<slug>. Same name on both sides, so the browser pairs them.

The site handed me a control group, too. Posts in one section of the blog don't have the paired name — and clicking those gives a plain crossfade, no morph. Accidental proof that the pairing is what does the work.

Thirty seconds of theory#

When a page calls document.startViewTransition(update), the browser:

  1. snapshots the current state of the page,
  2. runs your update callback (the DOM changes underneath),
  3. snapshots the new state, and animates between the two.

By default the whole page crossfades. But any element with a view-transition-name gets its own snapshot pair, and if an element on the old page and an element on the new page share the same name, the browser treats them as the same thing and animates position and size between them.

That's the entire trick. No animation library, no FLIP math, no measuring layouts in JavaScript. You name two elements; the browser does the travel.

The Next.js 16 version#

Next.js 16 wires this into client-side navigation so you never call startViewTransition yourself. Two pieces.

Updated 2026-08-06. This post originally started with a third piece: turning on experimental.viewTransition in next.config.ts. Next.js 16.3 deleted that flag — the App Router now runs navigations inside the View Transitions API with no opt-in at all. On 16.2 and earlier you still need it. On 16.3 the key is gone from the config schema, so leaving it behind is a type error rather than a warning, which is a kind way to find out.

1. Wrap both elements with React's ViewTransition component — it comes from react itself, not from Next:

// app/page.tsx — the post list
import { ViewTransition } from "react";
 
<ViewTransition
  name={`post-title-${post.slug}`}
  share="morph"
  default="none"
>
  <h3>{post.title}</h3>
</ViewTransition>
// app/blog/[slug]/page.tsx — the article header
<ViewTransition name={`post-title-${slug}`} share="morph" default="none">
  <h1>{meta.title}</h1>
</ViewTransition>

Same name on both sides — that's the pairing. React is smart about it: the view-transition-name is only applied at transition time, and the pages themselves stay fully static. This blog is SSG end to end, and adding the morph didn't change that.

default="none" is the prop I wish I'd written on day one. Only the title you clicked gets paired across the navigation. Every other title in the list exists on the old page and not the new one, and an unpaired name falls back to default="auto" — React's own crossfade. So for months the posts you didn't click were quietly animating too, on the browser's default timing instead of the tokens below. Keep share explicit when you add it: with default="none" and no share, the pair stops morphing and you get nothing.

2. Set the timing in CSS. The share="morph" prop tags the pair with a class, so plain CSS can target it. The browser supplies the position/size keyframes; I only tell it how long to run, using the same motion tokens as every other transition on this site:

::view-transition-group(.morph) {
  animation-duration: var(--duration);
  animation-timing-function: var(--ease-standard);
}
 
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*),
  ::view-transition-group(*) {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
  }
}

Two details worth copying. Browsers without the API just skip the animation — navigation works fine, so this is progressive enhancement all the way down. And for prefers-reduced-motion, collapse the duration to 0s instead of setting animation: none — a zero-duration animation keeps the whole lifecycle intact (same events, same promises, just instant), while animation: none removes the animations entirely and changes the behaviour, not just the speed.

The stability I misread#

Losing the flag sent me looking at what had actually stabilised, and it turns out there are two layers here. Only one of them settled.

The layer that settled is Next's integration — the part that notices you navigated and wraps the update in a transition. That's all the flag ever gated, and it's now unconditional.

React's <ViewTransition> component is the other layer, and it's still a canary API. It isn't in a stable React release at all: import { ViewTransition } from "react" resolves to the React that Next vendors, not the react sitting in your package.json. You can catch it in the act:

node -p "'ViewTransition' in require('react')"   # false

So the props in the snippets above — share, default — can still change without a Next major version to announce it. That's fine for a blog that degrades to an ordinary navigation when anything goes wrong. It's worth knowing before you put it somewhere load-bearing.

The element I un-animated#

My first version paired the post date too — and it was motion sickness. The date sits above the title on the home page but below it on the post page, so the two elements crossed paths mid-flight. I removed it. The API makes morphing cheap; the real work is deciding what not to morph.

The part I got for free#

One last thing the dissection taught me: most of the smoothness I admired wasn't the transition at all. Next.js already prefetches Link targets as they scroll into view (in production) and swaps pages client-side, so the "instant" feel was there before I touched anything. The travelling title is just the last layer of seasoning — but it's the layer you notice.