Neoverse-Docs
About the Project

Route Transition System

Five transition semantics, asymmetric behavior, DOM cloning, content particle transitions, and contain isolation

Primary author:
AI Summary of This Chapter

TransitionProvider is the single state entry point for transitions, and transition-policy.ts maps source and target routes to five transition semantics as pure functions. The aperture kind uses a sanitized DOM clone in the layer; the content kind uses WebGL particle exits; overview / surface / crossfade rely on the target page's own enter animations. The transition layer is fully isolated with contain: strict; clones set will-change while alive and immediately release the compositing layer after completion.

1. System Overview

Route transitions are coordinated by six files under src/features/transition/:

Text
transition-types.ts        type contracts
transition-policy.ts       pure-function route classifier → 5 transition semantics
transition-controller.ts   DOM cloning and geometry helpers
transition-provider.tsx    central Provider, capture-phase click listener
transition-layer.tsx       inert visual layer
transition-link.tsx        next/link wrapper, declares transition intent
back-link.tsx              declarative back link
content-particle-transition.ts  WebGL particle exit between docs

TransitionProvider is the single state entry point for transitions; TransitionLink only expresses navigation intent and doesn't duplicate path checks, click coordinates, or cleanup logic across business components.

The Provider is also the sole writer of the idle → capturing → leaving → entering lifecycle in runtime/navigation. Deferred TOC, Sidebar, Search Spotlight, and Home Template subscribe through useSyncExternalStore; they no longer depend on a global CustomEvent, a root-attribute MutationObserver, or transition datasets for business state. Root datasets remain only as CSS visual projections.

2. Type Contracts

transition-types.ts defines the core types:

  • TransitionPhase: idle | preparing | navigating | revealing | settling, describing the five phases of a transition;
  • TransitionKind: auto | aperture | overview | surface | content | crossfade | none, describing the transition kinds;
  • TransitionIntent: { kind, origin: {x, y}, sourcePath, targetPath }, describing a single transition intent.

3. Route Policy

transition-policy.ts is a pure-function route classifier that maps source and target routes to five transition semantics.

Route Classification

Routes are split by locale and matched against the rest path, falling into four categories:

CategoryMatching rule
homeroot path /
docs/docs/...
guestbook/guestbook
otheranything else

Transition Selection Rules

Source → TargetTransition kindSemantics
same pathnoneno-op
locale change onlycrossfadefade for language switching
home / guestbook → docsapertureradial aperture reveals the docs
docs → home / guestbookoverviewdocs shrink back to overview
home ↔ guestbooksurfacesurface switch
docs → docscontentcontent particle exit
othersurfacedefault surface switch

isSamePageHashNavigation() detects same-page hash navigation and directly returns none to preserve native browser behavior.

4. Asymmetric Transition Behavior

isCrossRouteGroupTransition is asymmetric:

  • docs → home / guestbook (cross-group) returns false, triggering the page-enter (opacity + scale + blur) enter animation;
  • home / guestbook → docs (cross-group) returns true, keeping the aperture (radial reveal of the DOM snapshot) transition.

This asymmetric design stems from the visual intent of the two directions: entering docs expands an aperture from the click position, emphasizing "diving in"; returning home lets the docs page shrink and fade out by itself, emphasizing "exiting".

5. DOM Cloning Utilities

transition-controller.ts provides cloneTransitionSource() and geometry calculation helpers.

cloneTransitionSource

Text
1. find main or #nd-docs-layout (excluding inside the transition layer)
2. create .nd-transition-clone viewport div
3. clone the source node
4. replace all ids with data-nd-transition-source-id (keep style hooks)
5. remove canvas / video / audio / iframe / script / object / embed / .immersive-particle-layer
6. interactive elements get tabIndex = -1, remove contenteditable
7. clone.inert = true, aria-hidden = "true"
8. if there is a scroll offset, translateY(-scrollY)

Removing media elements prevents videos in the snapshot from continuing to play or scripts from executing; removing the particle layer keeps particle animations in the clone from running; data-nd-transition-source-id keeps style hooks (such as styles matched by #id selectors) while avoiding duplicate ids breaking page anchors.

calculateRevealRadius

Computes the maximum distance from the origin (click coordinates) to the four viewport corners plus an extra 7vw (clamped 48-140px) as the target radius of the radial mask animation.

6. Centralized Provider

transition-provider.tsx is the central controller of transitions.

Click Capture

document.addEventListener('click', handleClick, { capture: true }) listens to all clicks in the capture phase:

  • find the nearest a[href];
  • filter modifier keys, new windows, and download links via isPlainInternalNavigation;
  • prefer the explicit data-transition declaration, otherwise call selectTransition;
  • hash navigation and same-path links are no-ops directly.

Intent Building (prepare)

When prefersReducedMotion() is active, ROUTE_TRANSITION_START_EVENT is still published so dependents can respond, but the animation is skipped.

Preparation behavior per kind:

KindPreparation behavior
aperturecloneTransitionSource() places the clone in the layer, sets the origin and max-radius CSS variables
contentcreateContentParticleTransition() creates the WebGL particle exit, captures the #nd-page clone
overview / surface / crossfadelayer stays hidden, relies on the target page's own enter animation

overview / surface / crossfade don't show a clone, because showing one produces a "flashback" frame — the reader has already seen the target page start entering, and showing the source clone again would feel like visual regression.

Target Reveal (useLayoutEffect)

When the pathname changes, validate the intent and mark data-nd-route-transition:

  • aperture and content set the layer data-phase = revealing;
  • compute settleDuration (content waits for contentEnterDelay + contentEnter + buffer).

Cleanup

cleanup() atomically cancels snapshots, timers, and root node animations; explicitly resetting the clone's will-change: auto ensures the browser immediately releases the compositing layer.

Pre-warming

requestIdleCallback pre-warms the WebGL renderer while docs routes are idle, avoiding the delay of creating a context on the first docs-to-docs navigation.

7. Content Particle Transition

content-particle-transition.ts implements the WebGL particle exit for docs-to-docs navigation, used only for the content kind.

Prerequisites

Depends on HTML-in-Canvas capture (drawElementImage + requestPaint) and WebGL2; when unsupported, keeps a lightweight fade-in.

Shared Renderer

sharedRenderer is a singleton WebGL2 context, reused through acquireRenderer() / releaseRenderer() to avoid creating a new context for each transition.

Shaders

  • Vertex shader: particle grid → computes the travel path (smoke-like rise + leftward bend + rotational swirl + time-noise jitter);
  • Fragment shader: samples the source content texture with a circular mask, blending the dark-mode content into the light target.

Capture and Rendering

  • onpaint callback: drawElementImage captures the clone, clip() crops it to the actual content card bounds;
  • play(onFirstFrame): starts the animation once the first frame is ready;
  • destroy(): cleans up all resources (textures, buffers, shader programs).

Particle Presets

Tierdensityspreadswirl
high218028
mediumhalvedhalvedhalved

8. Transition Layer

transition-layer.tsx is a single inert visual layer:

HTML
<div aria-hidden="true" data-phase="idle" hidden id="nd-transition-layer" />

#nd-transition-layer is fully isolated with contain: strict, and pointer-events: none doesn't intercept interaction. .nd-transition-clone sets will-change: transform, opacity to hint the compositing layer, effective only while the clone is alive.

transition-link.tsx is a wrapper around next/link that only declares data-transition semantics. It doesn't execute the transition itself; it just lets TransitionProvider's click capture know this is a link with a declared transition kind.

back-link.tsx is a declarative back link — ArrowLeft icon + special-page__back-link class, used on special pages (such as the guestbook) to return home.

10. Transition Styles

src/features/transition/styles.css defines the transition visual rules.

Radial Mask

@property --transition-radius registers a custom property for the radial mask animation. [data-transition="aperture"] uses mask-image: radial-gradient(...), and the nd-aperture-reveal animation expands the radius.

Particle Capture

[data-particle-capture] clears the clone surface during capture (transparent border / background / box-shadow / backdrop-filter), keeping only the text as the particle source.

Key Enter Animations

KindEnter animation
aperture landscale 1.02 → 1, no opacity change (the page is already visible through the aperture)
overview enterpositional shrink only, opacity stays 1 (avoids a brightness dip from stacking with the clone fade-out)
surface hometransforming the whole home page is disabled (a fixed blurred environment layer would position relative to the over-long page); only .home-hero__content shrinks
content enternd-route-content-enter opacity 0 → 1, delayed by --nd-delay-content-enter

Why surface doesn't transform the whole home page

The surface transition intentionally doesn't transform the whole home page (html[data-nd-route-transition="surface"] .home-page { animation: none; }). Because the fixed blurred environment layer (.home-page::before) would instead position relative to the over-long page, triggering expensive full-page repaints. Only .home-hero__content does a positional shrink, keeping the visual transition while avoiding the performance problem.

Mobile Adjustments

The content kind uses z-index: 30 on mobile, avoiding the sidebar drawer's z-index: 40.

reduced-motion

prefers-reduced-motion: reduce hides the transition layer and disables all enter animations, keeping instant content switching.

11. Transition Completion and Timeouts

After a transition completes, fails, or times out, the Provider destroys clones and temporary state:

  • TRANSITION_TIMEOUT_MS.navigation = 8000: navigation timeout;
  • TRANSITION_TIMEOUT_MS.settleBuffer = 140: settle phase buffer.

Full page HTML isn't written to sessionStorage; same-page hash navigation keeps native browser behavior, and the reduced-motion preference takes the degraded path.

12. Key Files

FileResponsibility
src/features/transition/transition-types.tstype contracts
src/features/transition/transition-policy.tsroute-to-transition-semantics mapping
src/features/transition/transition-controller.tsDOM cloning and geometry computation
src/features/transition/transition-provider.tsxtransition state and cleanup
src/features/transition/transition-layer.tsxinert visual layer
src/features/transition/transition-link.tsxnext/link wrapper
src/features/transition/back-link.tsxdeclarative back link
src/features/transition/content-particle-transition.tsWebGL particle exit between docs
src/features/transition/styles.csstransition visual rules
src/runtime/motion/config.tstransition durations and timeout config
src/runtime/navigation/store.tsCross-module navigation lifecycle Store
src/adapters/fumadocs/dom.tsFumadocs DOM adapter interface

On this page

Discussion

Welcome to share your thoughts and suggestions