Implementation Workflow
Follow these steps in order when adding view transitions to an app. Each step builds on the previous one.
Overview
Implementation Workflow
Follow these steps in order when adding view transitions to an app. Each step builds on the previous one.
Step 1: Audit the App
Before writing any code, scan the codebase thoroughly. Search for:
- Every `` and
router.push— these are your navigation triggers. Open every file that contains one. - Every `` boundary — each one is a candidate for a reveal animation. Check what its fallback renders.
- Every page/route component — list them all. Each page needs a VT placement decision.
- Persistent elements — headers, navbars, sidebars, sticky controls that stay on screen across navigations. These need
viewTransitionNameisolation. - Shared visual elements — images, cards, or avatars that appear on both a source and target view (e.g., a thumbnail in a list and the same image on a detail page).
- Skeleton-to-content control pairs — if a Suspense fallback renders a control (search input, tab bar) that also exists in the real content, both need a matching
viewTransitionName.
Then classify every navigation and produce a navigation map:
| Route | Navigates to | Direction | VT pattern |
|-----------------|----------------------|--------------|-----------------------|
| / | /detail/[id] | forward | directional slide |
| /detail/[id] | / | back | directional slide |
| /detail/[id] | /detail/[other] | lateral | key+share crossfade |
| /tab/[a] | /tab/[b] | lateral | key+share crossfade |
| (Suspense) | (content loads) | — | slide-up reveal |
For each shared element (name prop), note every navigation where a pair forms and where it doesn't — this determines whether you need enter/exit as a fallback alongside share.
Step 2: Add CSS Recipes
Copy the complete CSS recipe set from css-recipes.md into your global stylesheet. This includes timing variables, shared keyframes, fade, slide (vertical), directional navigation (forward/back), shared element morph, persistent element isolation, and reduced motion.
Do not write your own animation CSS — the recipes handle staggered timing, motion blur on morphs, and reduced motion that are easy to get wrong. You can customize timing variables (--duration-exit, --duration-enter, --duration-move) after the initial setup.
Step 3: Isolate Persistent Elements
For every persistent element identified in Step 1, add a viewTransitionName style to pull it out of the page content's transition snapshot:
<header style={{ viewTransitionName: "site-header" }}>...</header>
Then add CSS to prevent the element from animating during page transitions:
::view-transition-group(site-header) {
animation: none;
z-index: 100;
}
If the element uses backdrop-blur or backdrop-filter, use the backdrop-blur workaround from css-recipes.md instead (hide old snapshot, disable animation on new).
If a Suspense fallback mirrors a persistent control (e.g., a skeleton search input), give both the real control and the skeleton the same viewTransitionName so they morph in place.
Step 4: Add Directional Page Transitions
For hierarchical navigations identified in Step 1, tag the navigation direction using addTransitionType inside startTransition:
startTransition(() => {
addTransitionType('nav-forward');
router.push('/detail/1');
});
Then wrap each page component (not layout) in a type-keyed ``:
<div>...page content...</div>
The nav-forward and nav-back CSS classes from css-recipes.md produce horizontal slides. For simpler apps where directional motion isn't needed, a bare `` wrapper with enter="fade-in" / exit="fade-out" works too.
Rules:
- Always pair
enterwithexit— without an exit animation, the old page disappears instantly while the new one animates in. - Always include
default: "none"in type map objects anddefault="none"on the component — otherwise it fires on every transition. - Place the directional `` in each page component, not in a layout. Layouts persist across navigations and never trigger enter/exit.
- Only use directional slides for hierarchical navigation. Lateral/sibling navigation (tab-to-tab) should use a bare `` (cross-fade) or
default="none".
Step 5: Add Suspense Reveals
For every boundary identified in Step 1, wrap the fallback and content in separates:
}
>
This example uses slide-down / slide-up for directional vertical motion. For a simpler reveal, a bare around the gives a cross-fade with zero configuration. Choose based on the spatial meaning — consult the "Choosing the Right Animation Style" table in the main skill file.
Rules:
- Always use
default="none"on the content `` to prevent re-animation on revalidation or unrelated transitions. - Use simple string props (not type maps) on Suspense ``s — Suspense resolves fire as separate transitions with no type, so type-keyed props won't match.
Step 6: Add Shared Element Transitions
For every shared visual element identified in Step 1, add matching named `` wrappers on both the source and target views:
// On the source view (e.g., list/grid page)
// On the target view (e.g., detail page) — same name
The share="morph" class uses the morph recipe from css-recipes.md (controlled duration + motion blur). For a simpler cross-fade, use share="auto" (browser default).
For list items that should animate individually on enter/exit, add a per-item `` wrapper:
{items.map(item => (
))}
Rules:
- Names must be globally unique — use prefixes like
photo-${id}. - Add
default="none"on list-side shared elements to prevent per-item cross-fades on filter/search updates. - Never use a fade-out exit on pages with shared element morphs — the page dissolving conflicts with the morph, causing a flash. Use a directional slide exit instead.
Step 7: Verify Each Navigation Path
Walk through every row in the navigation map from Step 1 and confirm:
- Does the VT mount/unmount on this navigation, or does it stay mounted (same-route)?
- For named VTs: does a shared pair form? If not, does
enter/exitprovide a fallback? - Does
default="none"block an animation you actually want? - Do persistent elements stay static (not sliding with page content)?
- Do Suspense reveals animate independently from directional navigations?
If any path produces no animation or competing animations, revisit the relevant step.
Common Mistakes
- Bare `` without props — without
default="none", it fires the browser's default cross-fade on every transition (every navigation, every Suspense resolve, every revalidation). Always setdefault="none"and explicitly enable only the triggers you want. - **Directional
in a layout** — layouts persist across navigations and never unmount/remount. `enter`/`exit` props won't fire on route changes. Place the outer type-keyedin each page component. - Fade-out exit with shared element morphs — the page dissolving conflicts with the morph. Use a directional slide exit instead.
- Writing custom animation CSS — the recipes in
css-recipes.mdhandle staggered timing, motion blur on morphs, and reduced motion. Copy them; don't reinvent them. - Missing
default: "none"in type-keyed objects — TypeScript requires adefaultkey, and without it the fallback is"auto"which fires on every transition. - Type maps on Suspense reveals — Suspense resolves fire as separate transitions with no type. Type-keyed props won't match — use simple string props instead.
- Raw
viewTransitionNameCSS to trigger animations — React only callsdocument.startViewTransitionwhen `` components are in the tree. A bareviewTransitionNamestyle is for isolating elements from a parent's snapshot, not for triggering animations. updatetrigger for same-route navigations — nested VTs inside the content steal the mutation from the parent, soupdatenever fires on the outer VT. Usekey+name+shareinstead.
For Next.js-specific implementation steps (config flag, transitionTypes on ``, same-route dynamic segments), see nextjs.md.