EngineTransitions+
EngineTransitions+ is Next.js Engine's page, navigation, and layout transition runtime.
It is built around the browser View Transitions API when that API exists. NE keeps a normal Next.js navigation path as the fallback, so a transition is an enhancement rather than a requirement for the page to work.
Normal internal links stay on next/link. The heavier transition runtime is loaded only when an animated link is actually rendered.
Start here
The shortest way to use a transition is to put one preset name in cprop.link.transition.
{
type: "link",
props: {
href: "/products",
content: "Products",
cprop: {
link: {
transition: "slide",
},
},
},
}That is enough. NE chooses the preset defaults, runs the View Transition when supported, and falls back to normal navigation when it is not.
For more control, use an object:
{
type: "link",
props: {
href: "/products",
content: "Products",
cprop: {
link: {
transition: {
type: "portal",
duration: 620,
direction: "right",
strength: 1.1,
origin: "pointer",
config: {
rotation: 8,
blur: 5,
},
},
},
},
},
}Where transitions can be used
EngineLink / schema link
{
type: "link",
props: {
href: "/about",
content: "About",
cprop: {
link: {
transition: "fade",
},
},
},
}EngineNav item
{
label: "Gallery",
href: "/gallery",
cprop: {
link: {
transition: "reveal",
},
},
}Direct JSX
import { EngineTransitionLink } from "nextjs-engine";
export function GalleryLink() {
return (
<EngineTransitionLink
href="/gallery"
transition="wipe"
>
Open gallery
</EngineTransitionLink>
);
}Programmatic state/layout changes
"use client";
import { useState } from "react";
import { useEngineTransitions } from "nextjs-engine";
export function CompactToggle() {
const [compact, setCompact] = useState(false);
const transitions = useEngineTransitions();
return (
<button
onClick={() => transitions.run(() => {
setCompact((value) => !value);
}, "layout")}
>
{compact ? "Large layout" : "Compact layout"}
</button>
);
}run() is useful when the URL does not change but the UI does.
run() is also safe to call from React effects, observers, timers, subscriptions, and external-store reactions. The runtime yields one microtask before entering the synchronous View Transition update, so React can finish the current render/commit lifecycle before NE uses flushSync() for snapshot correctness.
"use client";
import { useEffect, useState } from "react";
import { useEngineTransitions } from "nextjs-engine";
export function ScrollDrivenScene({ sceneIndex }: { sceneIndex: number }) {
const [activeIndex, setActiveIndex] = useState(sceneIndex);
const transitions = useEngineTransitions();
useEffect(() => {
void transitions.run(
() => setActiveIndex(sceneIndex),
"slide",
);
}, [sceneIndex, transitions]);
return <div>Scene {activeIndex}</div>;
}This is the intended path for EngineScroll-driven UI, async data, media state, timers, and other lifecycle-driven state changes; callers do not need setTimeout() or requestAnimationFrame() workarounds.
App-level theme and visual updates
Do not call document.startViewTransition() directly beside EngineTransitions+. The browser owns only one native View Transition at a time, so a theme transition and route transition can otherwise supersede each other and reject their native lifecycle promises with AbortError.
Use NE's shared coordinator for app-level DOM updates that need a native transition but do not need an EngineTransitions+ preset:
"use client";
import { coordinateEngineViewTransition } from "nextjs-engine";
export function chooseTheme(theme: "day" | "night") {
const apply = () => {
document.documentElement.dataset.theme = theme;
};
void coordinateEngineViewTransition(apply);
}The default conflict behavior is skip: when an NE transition is already active, the theme is still applied immediately, but a second native visual transition is not started. This keeps route navigation responsive and prevents two callers from fighting over the browser API.
NE navigation uses conflict: "replace" internally so the user's newest navigation can skip an older visual transition. In both modes, the coordinator observes ready, updateCallbackDone, and finished immediately. An intentional native AbortError resolves as a normal "cancelled" status; an error thrown by the actual update callback still rejects.
Use useEngineTransitions().run() for React state updates that need an NE preset and its synchronous snapshot handling. Use coordinateEngineViewTransition() for imperative theme/class/attribute updates that would otherwise call the native browser API directly.
Programmatic navigation
"use client";
import { useEngineTransitions } from "nextjs-engine";
export function OpenProductButton() {
const transitions = useEngineTransitions();
return (
<button
onClick={() => transitions.push("/product/42", {
type: "depth",
direction: "right",
})}
>
Open product
</button>
);
}useEngineTransitions() returns:
interface EngineTransitionsController {
run(
update: () => void | Promise<void>,
transition?: EngineTransitionInput,
): Promise<void>;
push(
href: string,
transition?: EngineTransitionInput,
context?: EngineTransitionRunContext,
): Promise<void>;
replace(
href: string,
transition?: EngineTransitionInput,
context?: EngineTransitionRunContext,
): Promise<void>;
}Defaults are layout for run() and fade for push() / replace().
The 20 transition presets
The names are intentionally short and easy to remember.
Name | Default | What it looks like | Best settings to change |
|---|---|---|---|
| 320 ms | Old content fades away while new content appears |
|
| 420 ms | Old and new frames move in opposite directions |
|
| 380 ms | The frame shrinks/grows around an origin |
|
| 480 ms | Root stays soft while named elements morph between layouts/pages |
|
| 420 ms | Keeps the root almost still and lets shared elements move |
|
| 520 ms | New page opens from a circular reveal point |
|
| 460 ms | New page is uncovered from one side |
|
| 520 ms | New page opens outward from the middle |
|
| 560 ms | Old/new frame closes and opens like curtains |
|
| 500 ms | Uses stepped timing and a blocky captured-frame feel |
|
| 520 ms | Blurred dissolve between snapshots |
|
| 620 ms | Skewed/stretching liquid-like swap |
|
| 440 ms | Stretches the moving frame in its travel direction |
|
| 520 ms | Old frame moves backward in depth while new frame comes forward |
|
| 560 ms | Flips the frame around the X or Y axis |
|
| 680 ms | Rotates the snapshot like turning a page |
|
| 620 ms | New frame pops in, overshoots, and settles |
|
| 560 ms | Old frame spreads/jitters away while the new frame assembles |
|
| 420 ms | Creates a quick red/blue channel separation effect |
|
| 620 ms | Old frame collapses toward a point; new frame expands from it |
|
instant is also accepted. It skips the animation and uses normal navigation/update behavior.
Compatibility aliases
Old or longer names continue to work.
Alias | Current name |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Unknown names fall back to fade. Development builds also print a warning.
Common transition object
transition: {
type: "slide",
duration: 420,
easing: "ease-out",
direction: "right",
strength: 1,
origin: "center",
shared: ["hero-image"],
config: {
distance: 96,
blur: 8,
},
}Top-level settings
Setting | Type | Meaning |
|---|---|---|
| transition name | Which preset to use |
| number | Milliseconds. Clamped to |
| string | CSS/Web Animations easing. |
|
| Main travel direction |
| number | General effect multiplier. Clamped internally where needed |
|
| Origin for reveal/zoom/portal-like effects |
| string or string[] | DOM ids that should receive temporary shared view-transition names |
| object | Preset-specific settings |
origin: "pointer" uses the click location automatically for EngineTransitionLink.
For a programmatic navigation, provide the pointer yourself when you want pointer-origin behavior:
await transitions.push(
"/details",
{
type: "portal",
origin: "pointer",
},
{
pointer: {
x: 420,
y: 280,
},
},
);Config fields that are active today
The public EngineTransitionConfig type intentionally contains some reserved future knobs. The current renderer actively reads these fields:
Field | Used by | Meaning |
|---|---|---|
| slide, smear | Travel distance in px |
| dissolve, liquid, smear, depth, scatter, portal | Blur amount before the preset multiplies/clamps it |
| zoom | Starting/ending scale |
| liquid, portal | Rotation/skew strength in degrees |
| split, curtain, flip, page-turn |
|
| depth | Z travel distance |
| depth, flip, page-turn | CSS perspective distance |
| pixel | Controls stepped timing density |
| scatter | Scatter travel amount |
| rgb | RGB channel separation offset |
Other fields currently present on EngineTransitionConfig are reserved for richer future implementations. Do not rely on a reserved field producing a visible change until it is listed in this table.
Shared morphs
Shared morphs use normal DOM ids. No extra transition wrapper is required.
Source page:
{
type: "image",
props: {
id: "product-cover",
src: "/gpu-card.png",
alt: "GPU",
},
}Destination page:
{
type: "image",
props: {
id: "product-cover",
src: "/gpu-large.png",
alt: "GPU",
},
}Link:
{
type: "link",
props: {
href: "/product/gpu",
content: "View GPU",
cprop: {
link: {
transition: {
type: "morph",
shared: "product-cover",
},
},
},
},
}During the transition NE temporarily gives that id a sanitized view-transition-name, captures both snapshots, then restores the previous inline value.
Multiple elements work too:
transition: {
type: "layout",
shared: [
"product-cover",
"product-title",
"buy-button",
],
}A shared id must exist in the relevant old/new DOM when the browser captures that side of the transition. Missing ids are simply ignored.
Recipes
Simple page fade
transition: "fade"Slide left when opening a deeper page
transition: {
type: "slide",
direction: "right",
config: {
distance: 120,
},
}Circular reveal from the clicked button
transition: {
type: "reveal",
origin: "pointer",
duration: 520,
}Strong 3D push
transition: {
type: "depth",
config: {
depth: 260,
perspective: 1200,
blur: 12,
},
}Pixel-ish fast swap
transition: {
type: "pixel",
duration: 380,
config: {
pixelSize: 10,
},
}Layout rearrangement without navigation
await transitions.run(() => {
setSortMode("price");
}, {
type: "layout",
shared: ["card-a", "card-b", "card-c"],
});Runtime behavior
EngineTransitions+ deliberately preserves normal browser/Next.js behavior where animation would be the wrong thing to intercept.
- ordinary internal links stay on
next/link; - the transition link/runtime is lazy-loaded only when an animated internal link is rendered;
- external URLs,
_blank,mailto:, and other non-HTTP schemes use native anchors; - Ctrl/Cmd/Shift/Alt-click and non-left clicks keep native browser behavior;
- hash-only navigation is not hijacked by the page-transition runtime;
- unsupported browsers perform the update/navigation without animation;
prefers-reduced-motion: reduceusers receive effectively instant transitions;run()yields one microtask before its synchronous React snapshot update, so lifecycle/effect-driven transitions do not callflushSync()from inside React's current commit stack;- native View Transitions have one shared NE coordinator across page, theme, and other app-level visual updates;
- app-level coordinated updates skip only their extra visual effect while another transition is active, but still apply the update;
- a newly started NE navigation skips the previous active visual transition instead of building a queue;
- intentional native cancellation is observed and resolves normally rather than surfacing as an unhandled
AbortError; - shared-element inline
view-transition-namevalues are restored after the transition; durationis clamped so accidental huge values cannot lock the page in a multi-minute animation.
Browser fallback
The runtime checks document.startViewTransition at the moment the transition runs.
Conceptually:
transition requested
↓
reduced motion or instant?
├─ yes → update/navigate normally
└─ no
↓
View Transitions API available?
├─ no → update/navigate normally
└─ yes → capture old/new snapshots + play NE presetThat means you do not need separate browser-specific transition code.
Current implementation boundary
The 20 presets currently animate native View Transition snapshots using Web Animations/CSS-compatible transforms, clips, opacity, filters, and 3D transforms.
Names such as pixel, dissolve, and liquid are real distinct presets, but they are not yet EngineShader framebuffer passes. A future shader-backed transition renderer can replace selected visual implementations without changing the public preset names or the config shape.
This separation is intentional:
EngineTransitions+
= when/how state A becomes state B
EngineShader
= how GPU-owned pixels are produced/processedSee engineshader.md for the current GPU surface system.
Public exports
import {
ENGINE_TRANSITIONS,
EngineTransitionLink,
coordinateEngineViewTransition,
isKnownEngineTransition,
navigateWithEngineTransition,
normalizeEngineTransitionType,
resolveEngineTransition,
runEngineTransition,
useEngineTransitions,
} from "nextjs-engine";Useful public types include:
EngineTransitionAlias
EngineTransitionAxis
EngineTransitionConfig
EngineTransitionDirection
EngineTransitionEasing
EngineTransitionInput
EngineTransitionName
EngineTransitionOptions
EngineTransitionPointer
EngineTransitionRunContext
EngineTransitionsController
EngineViewTransitionConflict
EngineViewTransitionOptions
EngineViewTransitionStatus
ResolvedEngineTransitionresolveEngineTransition() is mostly useful for tooling, testing, editors, or custom integrations. Most app code should use a transition name/object directly.
Troubleshooting
Nothing animates
Check these first:
- The link is internal, not an external URL.
- The transition is not
instant. - The browser supports the View Transitions API.
- The OS/browser does not currently request reduced motion.
- For shared morphs, the ids exist in the old/new page snapshots.
The page should still navigate even when animation is unavailable.
A config field does nothing
Check the Config fields that are active today table above. Some typed fields are reserved for future richer implementations.
Portal/reveal starts in the middle instead of the cursor
origin: "pointer" needs pointer coordinates. EngineTransitionLink supplies them automatically. Programmatic push() / replace() calls must pass context.pointer if they want the same behavior.
A new click interrupts the old transition
That is expected. NE skips the active transition so the UI responds to the user's newest action instead of waiting for an animation queue to finish.
Theme switching logs AbortError
Replace direct document.startViewTransition(applyTheme) calls with coordinateEngineViewTransition(applyTheme). The coordinator shares ownership with EngineTransitionLink, applies the theme even when navigation is active, and treats a superseded visual transition as normal cancellation.