Directional navigation with transitionTypes on Link
Use the transitionTypes prop on Link to tag forward and back navigations. For forward navigations (drilling deeper), use transitionTypes={['nav-forward']}. For back navigations returning to previous pages, use transitionTypes={['nav-back']}. The transition type is not automatic; you decide which links are "forward" and which are "back" based on your app's navigation hierarchy. Browser-initiated back navigations (the back button or swipe gestures) do not carry a transition type.
ViewTransition enter and exit object props for transition types
The enter and exit props on ViewTransition can accept an object keyed by transition type. Example: enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}. When a navigation carries the nav-forward type, the exit animation slides old content left and the enter animation slides new content in from the right. The default: "none" ensures that transitions without a type (browser back/forward, router.refresh(), Suspense reveals) produce no directional animation.
Page-level ViewTransition placement in layouts vs pages
For directional navigation animations, wrap page content in ViewTransition at the page.tsx level, not in the layout. Layouts persist across navigations, so enter and exit animations never fire in layouts. Every participating page needs the same ViewTransition wrapper for consistent behavior across navigation.
ViewTransition CSS pseudo-elements for animations
Animate view transitions using CSS pseudo-elements: ::view-transition-old(.classname) for exiting content, ::view-transition-new(.classname) for entering content, ::view-transition-group(.classname) for named groups, and ::view-transition-image-pair(.classname) for morphing images. You can set animation-duration, animation-delay, and apply @keyframe animations to these pseudo-elements.
Anchoring header during directional slides
To keep a header fixed during directional slide transitions, assign it a viewTransitionName (e.g., style={{ viewTransitionName: 'site-header' }}). Then suppress its animation in CSS: ::view-transition-group(site-header) { animation: none; z-index: 100; } and ::view-transition-old(site-header) { display: none; }. The display: none prevents a flash where both old and new headers are briefly visible. The z-index: 100 ensures the header renders above sliding content.
Keeping page interactive during transitions
While a transition runs, the ::view-transition overlay captures pointer events, losing clicks during animation. Allow them to pass through by setting ::view-transition { pointer-events: none; }. This restores interactivity for unnamed content. Hit-testing still skips named participants (like anchored headers) for the transition's duration, so keep transitions short and avoid naming elements the user clicks rapidly.
Respecting prefers-reduced-motion in view transitions
To respect user motion preferences, disable all animation durations for users with prefers-reduced-motion: @media (prefers-reduced-motion: reduce) { ::view-transition-old(*), ::view-transition-new(*), ::view-transition-group(*) { animation-duration: 0s !important; animation-delay: 0s !important; } }. This makes content swap instantly, matching the browser's default behavior. A more refined approach would preserve crossfades and opacity transitions while removing only positional movement.
Same-route content crossfade with ViewTransition
To crossfade content within the same route (like switching between tabs), use ViewTransition with key set to the current identifier. When the key changes, React triggers a transition. Example: <ViewTransition key={slug} name="collection-content" share="auto" enter="auto" default="none"><CollectionGrid slug={slug} /></ViewTransition>. The share="auto" and enter="auto" props tell React to use its default crossfade animation. The name prop gives the container an identity so React knows what to animate. The key change makes React treat old and new content as an exit/enter pair instead of an in-place update.
View transition patterns and their meanings
Four core patterns communicate different meanings: (1) Shared element morphing communicates "Same thing, going deeper"; (2) Suspense reveal communicates "Data loaded"; (3) Directional slide communicates "Going forward / coming back"; (4) Same-route crossfade communicates "Same place, different content".
Example: Blur keyframe for morph customization
To soften a morph mid-flight with blur, first add share="morph" and default="none" to both ViewTransition components in the photo grid and photo-content examples. Then add to app/globals.css:
```css
::view-transition-group(.morph) {
animation-duration: 400ms;
}
::view-transition-image-pair(.morph) {
animation-name: via-blur;
}
@keyframes via-blur {
30% {
filter: blur(3px);
}
}
```
The blur hides pixel-level interpolation artifacts during the 400ms transition.
Example: Suspense reveal with slide animations
In app/photo/[id]/page.tsx:
```tsx
import { Suspense, ViewTransition } from 'react'
export default async function PhotoPage({ params }) {
const { id } = await params
return (
<Suspense
fallback={
<ViewTransition exit="slide-down" default="none">
<PhotoContentSkeleton />
</ViewTransition>
}
>
<ViewTransition enter="slide-up" default="none">
<PhotoContent id={id} />
</ViewTransition>
</Suspense>
)
}
```
The skeleton slides down and fades out while the real content slides up and fades in. The default="none" prevents animation during unrelated transitions.
Example: CSS for asymmetric Suspense reveal timing
Add to app/globals.css:
```css
:root {
--duration-exit: 150ms;
--duration-enter: 210ms;
--duration-move: 400ms;
}
::view-transition-old(.slide-down) {
animation:
var(--duration-exit) ease-out both fade reverse,
var(--duration-exit) ease-out both slide-y reverse;
}
::view-transition-new(.slide-up) {
animation:
var(--duration-enter) ease-in var(--duration-exit) both fade,
var(--duration-move) ease-in both slide-y;
}
@keyframes fade {
from {
filter: blur(3px);
opacity: 0;
}
to {
filter: blur(0);
opacity: 1;
}
}
@keyframes slide-y {
from {
transform: translateY(10px);
}
to {
transform: translateY(0);
}
}
```
Old content exits fast (150ms). New content fades in slower (210ms) with a delay, and slides longer (400ms). The delay equals the exit duration so new content waits for old to leave before becoming visible.
Example: Directional navigation with transitionTypes
In components/photo-grid.tsx, tag forward navigation:
```tsx
<Link href={`/photo/${photo.id}`} transitionTypes={['nav-forward']}>
{/* photo thumbnail */}
</Link>
```
In app/photo/[id]/page.tsx, tag back navigation:
```tsx
<Link href="/" transitionTypes={['nav-back']}>
← Gallery
</Link>
```
Then wrap page content in both app/page.tsx and app/photo/[id]/page.tsx with:
```tsx
<ViewTransition
enter={{
'nav-forward': 'nav-forward',
'nav-back': 'nav-back',
default: 'none',
}}
exit={{
'nav-forward': 'nav-forward',
'nav-back': 'nav-back',
default: 'none',
}}
default="none"
>
{/* page content */}
</ViewTransition>
```
Example: CSS for directional slides
Add to app/globals.css:
```css
::view-transition-old(.nav-forward) {
--slide-offset: -60px;
animation:
150ms ease-in both fade reverse,
400ms ease-in-out both slide reverse;
}
::view-transition-new(.nav-forward) {
--slide-offset: 60px;
animation:
210ms ease-out 150ms both fade,
400ms ease-in-out both slide;
}
::view-transition-old(.nav-back) {
--slide-offset: 60px;
animation:
150ms ease-in both fade reverse,
400ms ease-in-out both slide reverse;
}
::view-transition-new(.nav-back) {
--slide-offset: -60px;
animation:
210ms ease-out 150ms both fade,
400ms ease-in-out both slide;
}
@keyframes slide {
from {
translate: var(--slide-offset);
}
to {
translate: 0;
}
}
```
For nav-forward, old content slides left (-60px) and new slides in from right (60px). For nav-back, directions reverse. The 60px offset communicates direction without making users track fast-moving elements.
Example: Fixed header during directional slides
In components/header.tsx:
```tsx
<header style={{ viewTransitionName: 'site-header' }}>
{/* navigation links */}
</header>
```
In app/globals.css:
```css
::view-transition-group(site-header) {
animation: none;
z-index: 100;
}
::view-transition-old(site-header) {
display: none;
}
::view-transition-new(site-header) {
animation: none;
}
```
The header stays fixed while page content slides forward or back.
Example: Same-route content crossfade
In app/collection/[slug]/page.tsx:
```tsx
import { Suspense, ViewTransition } from 'react'
export default async function CollectionPage({ params }) {
const { slug } = await params
return (
<Suspense fallback={<CollectionGridSkeleton />}>
<ViewTransition
key={slug}
name="collection-content"
share="auto"
enter="auto"
default="none"
>
<CollectionGrid slug={slug} />
</ViewTransition>
</Suspense>
)
}
```
When the slug changes (e.g., clicking between photographer tabs), the grid crossfades between old and new content. The key={slug} change makes React treat it as an exit/enter pair. The surrounding layout does not move.
Example: Shared element morph in photo grid
In components/photo-grid.tsx, wrap thumbnails in ViewTransition:
```tsx
import { ViewTransition } from 'react'
import Image from 'next/image'
import Link from 'next/link'
function PhotoGrid({ photos }) {
return (
<div className="grid grid-cols-3 gap-3">
{photos.map((photo) => (
<Link key={photo.id} href={`/photo/${photo.id}`}>
<ViewTransition name={`photo-${photo.id}`}>
<Image src={photo.src} alt={photo.title} />
</ViewTransition>
</Link>
))}
</div>
)
}
```
Then in app/photo/[id]/photo-content.tsx, wrap the hero in ViewTransition with the same name:
```tsx
import { ViewTransition } from 'react'
import Image from 'next/image'
async function PhotoContent({ id }) {
const photo = await getPhoto(id)
return (
<ViewTransition name={`photo-${photo.id}`}>
<div style={{ position: 'relative', aspectRatio: '3 / 2' }}>
<Image src={photo.src} alt={photo.title} fill />
</div>
</ViewTransition>
)
}
```
Clicking a thumbnail morphs the image from thumbnail to hero position.
Partial Prefetching with Cache Components
With Partial Prefetching enabled via the partialPrefetching config (which requires Cache Components), prefetching switches from an all-or-nothing model to a per-route App Shell. One shell per route is shared across links, fetched once as the first link enters the viewport, reducing prefetch requests for pages with many links.
Partial Prefetching data streaming
With Partial Prefetching enabled, uncached data streams in after navigation behind the shell's <Suspense> boundaries. A link can also resolve its URL data (searchParams, params) at prefetch time with prefetch={true}.
Link prefetch for URL-dependent authenticated routes
A route that depends on URL params or searchParams values needs Link prefetch={true} on links pointing to it to opt into per-link prefetching, which resolves the per-link data ahead of the click.
Partial Prefetching for per-link data resolution
To enable per-link prefetching for routes with URL dependencies, enable the partialPrefetching flag in next.config.js or set prefetch = 'partial' on the segment.
Enable cacheComponents in next.config.ts
To use Cache Components, set cacheComponents: true in next.config.ts. The configuration should be: import type { NextConfig } from 'next'; const nextConfig: NextConfig = { cacheComponents: true, }; export default nextConfig;
use cache: private accepts cookies, headers, and searchParams
'use cache: private' accepts cookies(), headers(), and searchParams, but not connection().
Reading cookies() or headers() in plain use cache throws error
Reading cookies() or headers() directly inside a plain 'use cache' function throws an error. Either read the request value outside the cached function and pass it in, or use 'use cache: private' instead.
Use cache: private for user-sensitive cached data
Read session-derived data inside a 'use cache: private' scope to keep it in the browser only, never on the server. This matters when requirements forbid storing certain data server-side, even temporarily.