Page transitions (View Transitions)
How the docs site uses the browser-native View Transitions API for smooth navigation and locale-switch crossfades.
Page transitions (View Transitions)
The docs site uses the browser-native View Transitions API (document.startViewTransition) to add a smooth crossfade on page navigation and a distinct locale-switch transition, eliminating the previous "instant teleport" feeling of instant DOM replacement.
Implementation
The setup lives in two places:
src/components/view-transition.tsxships theTransitionLinkcomponent, theuseViewTransitionRouterhook, and thedetectLocaleSwitchhelper.useViewTransitionRouterwrapsnext/navigation'srouter.push/router.replaceindocument.startViewTransition, usingflushSyncinside the update callback so the new route state is captured synchronously.src/app/global.cssdeclares the::view-transition-old(root)/::view-transition-new(root)default crossfade, the:active-view-transition-type(locale-switch)scoped effect (crossfade plus a slight scale), thevt-fade/vt-locale-scale-enter/vt-locale-scale-exitkeyframes, and the reduced-motion fallback.
TransitionLink is injected through Fumadocs' RootProvider components={{ Link: TransitionLink }} extension point in src/components/provider.tsx, so sidebar, TOC, navbar, and breadcrumb links all route through it. The home pages (src/app/(home)/page.tsx and src/app/zh/page.tsx) import TransitionLink as Link, and onLocaleChange navigates via useViewTransitionRouter().push(url, { types: ["locale-switch"] }).
Shared-element transitions
Beyond the root crossfade, the site marks a few elements with view-transition-name so they persist or morph across navigation instead of being swept into the root snapshot:
-
Announcement banner (stable persistence).
AnnouncementBanner(rendered in the root layout, so present on every page) carries thevt-bannerclass, which sets a staticview-transition-name: announcement-banneringlobal.css. On any navigation the banner is captured as its own::view-transition-group(announcement-banner)and stays put (same position → static morph), so it no longer flickers with the root crossfade. On a locale switch the banner text crossfades inside the stable banner box. -
Home doc-link card → docs title (position morph). The three doc-link cards on the home pages pass
sharedName="hero-doc-card"toTransitionLink. On click,TransitionLinkimperatively setsview-transition-name: hero-doc-cardon the clicked card (viastyle.setProperty) before starting the transition, and starts the transition withtypes: ["shared-transition"]. The destinationDocsTitlecarries thevt-hero-targetclass, andglobal.cssscopes the matching name to that transition type::active-view-transition-type(shared-transition) .vt-hero-target { view-transition-name: hero-doc-card; }The browser then morphs the captured card (old position/size) into the doc title (new position/size), with the old card content crossfading into the new title. The inline name is cleared in a microtask after the old snapshot is captured, so it cannot linger and collide with a later transition.
No ghost elements on non-shared navigations
Because the destination name is gated behind :active-view-transition-type(shared-transition), the DocsTitle only receives a view-transition-name during a shared-transition. Doc→doc navigation, docs→home, locale switches, and the home "Open docs" button (which does not pass sharedName) never trigger that type, so DocsTitle stays part of the root crossfade and never floats as a detached "ghost" element. The view-transition-name uniqueness invariant holds because only the single clicked card is named on the source side and only one DocsTitle exists on the destination side.
Why manual startViewTransition instead of experimental.viewTransition
Next.js' experimental.viewTransition flag plus React's <ViewTransition> component were evaluated first but do not work on stable React: with react@19.2.7, both <ViewTransition> and addTransitionType are canary / experimental-channel-only exports, and stable react-dom@19.2.7 has no document.startViewTransition integration. Wrapping navigation manually works on stable React, is production-safe, adds no dependency, and degrades gracefully where the API is unsupported.
Graceful degradation and accessibility
- Browsers without
document.startViewTransitionfall back to the original instant navigation:TransitionLinkdefers tonext/linkandonLocaleChangecallsrouter.pushdirectly, with no console errors. @media (prefers-reduced-motion: reduce)zeroes every::view-transition-*animation duration and delay, so motion-sensitive users get instant swaps.
Last updated on