

Also from Kynth Studios


Also from Kynth Studios


Also from Kynth Studios
1# React View Transitions23**Version 1.0.0**4Vercel Engineering5March 202667> **Note:**8> This document is mainly for agents and LLMs to follow when implementing9> view transitions in React applications. Humans may also find it useful,10> but guidance here is optimized for automation and consistency by11> AI-assisted workflows.1213---1415## Abstract1617Guide for implementing smooth, native-feeling animations using React's View Transition API. Covers the `<ViewTransition>` component, `addTransitionType`, CSS view transition pseudo-elements, shared element transitions, Suspense reveals, list reorder, directional navigation, and Next.js integration. Includes a step-by-step implementation workflow, ready-to-use CSS animation recipes, and common mistake warnings.1819---2021## Table of Contents22231. [Core Reference](#when-to-animate)24 - [When to Animate](#when-to-animate)25 - [Availability](#availability)26 - [Core Concepts](#core-concepts)27 - [Styling with View Transition Classes](#styling-with-view-transition-classes)28 - [Transition Types](#transition-types)29 - [Shared Element Transitions](#shared-element-transitions)30 - [Common Patterns](#common-patterns)31 - [How Multiple VTs Interact](#how-multiple-vts-interact)32 - [Next.js Integration](#nextjs-integration)33 - [Accessibility](#accessibility)342. [Implementation Workflow](#implementation-workflow)35 - [Step 1: Audit the App](#step-1-audit-the-app)36 - [Step 2: Add CSS Recipes](#step-2-add-css-recipes)37 - [Step 3: Isolate Persistent Elements](#step-3-isolate-persistent-elements)38 - [Step 4: Add Directional Page Transitions](#step-4-add-directional-page-transitions)39 - [Step 5: Add Suspense Reveals](#step-5-add-suspense-reveals)40 - [Step 6: Add Shared Element Transitions](#step-6-add-shared-element-transitions)41 - [Step 7: Verify Each Navigation Path](#step-7-verify-each-navigation-path)42 - [Common Mistakes](#common-mistakes)433. [Patterns and Guidelines](#patterns-and-guidelines)444. [CSS Animation Recipes](#css-animation-recipes)455. [View Transitions in Next.js](#view-transitions-in-nextjs)4647---4849Animate between UI states using the browser's native `document.startViewTransition`. Declare *what* with `<ViewTransition>`, trigger *when* with `startTransition` / `useDeferredValue` / `Suspense`, control *how* with CSS classes. Unsupported browsers skip animations gracefully.5051## When to Animate5253Every `<ViewTransition>` should communicate a spatial relationship or continuity. If you can't articulate what it communicates, don't add it.5455Implement **all** applicable patterns from this list, in this order:5657| Priority | Pattern | What it communicates |58|----------|---------|---------------------|59| 1 | **Shared element** (`name`) | "Same thing — going deeper" |60| 2 | **Suspense reveal** | "Data loaded" |61| 3 | **List identity** (per-item `key`) | "Same items, new arrangement" |62| 4 | **State change** (`enter`/`exit`) | "Something appeared/disappeared" |63| 5 | **Route change** (layout-level) | "Going to a new place" |6465This is an implementation order, not a "pick one" list. Implement every pattern that fits the app. Only skip a pattern if the app has no use case for it.6667### Choosing Animation Style6869| Context | Animation | Why |70|---------|-----------|-----|71| Hierarchical navigation (list → detail) | Type-keyed `nav-forward` / `nav-back` | Communicates spatial depth |72| Lateral navigation (tab-to-tab) | Bare `<ViewTransition>` (fade) or `default="none"` | No depth to communicate |73| Suspense reveal | `enter`/`exit` string props | Content arriving |74| Revalidation / background refresh | `default="none"` | Silent — no animation needed |7576Reserve directional slides for hierarchical navigation (list → detail) and ordered sequences (prev/next photo, carousel, paginated results). For ordered sequences, the direction communicates position: "next" slides from right, "previous" from left. Lateral/unordered navigation (tab-to-tab) should not use directional slides — it falsely implies spatial depth.7778---7980## Availability8182- **Next.js:** Do **not** install `react@canary` — the App Router already bundles React canary internally. `ViewTransition` works out of the box. `npm ls react` may show a stable-looking version; this is expected.83- **Without Next.js:** Install `react@canary react-dom@canary` (`ViewTransition` is not in stable React).84- Browser support: Chromium 111+, Firefox 144+, Safari 18.2+. Graceful degradation.8586---8788## Core Concepts8990### The `<ViewTransition>` Component9192```jsx93import { ViewTransition } from 'react';9495<ViewTransition>96 <Component />97</ViewTransition>98```99100React auto-assigns a unique `view-transition-name` and calls `document.startViewTransition` behind the scenes. Never call `startViewTransition` yourself.101102### Animation Triggers103104| Trigger | When it fires |105|---------|--------------|106| **enter** | VT first inserted during a Transition |107| **exit** | VT first removed during a Transition |108| **update** | DOM mutations inside a VT. With nested VTs, mutation applies to the innermost one |109| **share** | Named VT unmounts and another with same `name` mounts in same Transition |110111Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate.112113### Critical Placement Rule114115VT only activates enter/exit if it appears **before any DOM nodes**:116117```jsx118// Works119<ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition>120121// Broken — div wraps the VT122<div><ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition></div>123```124125---126127## Styling with View Transition Classes128129Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations.130131```jsx132<ViewTransition default="none" enter="slide-in" exit="slide-out" share="morph" />133```134135If `default` is `"none"`, all triggers are off unless explicitly listed.136137### CSS Pseudo-Elements138139- `::view-transition-old(.class)` — outgoing snapshot140- `::view-transition-new(.class)` — incoming snapshot141- `::view-transition-group(.class)` — container142- `::view-transition-image-pair(.class)` — old + new pair143144---145146## Transition Types147148Tag transitions with `addTransitionType` so VTs can pick different animations. Call it multiple times to stack types — different VTs in the tree react to different types:149150```jsx151startTransition(() => {152 addTransitionType('nav-forward');153 addTransitionType('select-item');154 router.push('/detail/1');155});156```157158Map types to CSS classes. Works on `enter`, `exit`, **and** `share`:159160```jsx161<ViewTransition162 enter={{ 'nav-forward': 'slide-from-right', 'nav-back': 'slide-from-left', default: 'none' }}163 exit={{ 'nav-forward': 'slide-to-left', 'nav-back': 'slide-to-right', default: 'none' }}164 share={{ 'nav-forward': 'morph-forward', 'nav-back': 'morph-back', default: 'morph' }}165 default="none"166>167 <Page />168</ViewTransition>169```170171`enter` and `exit` don't have to be symmetric. For example, fade in but slide out directionally:172173```jsx174<ViewTransition175 enter={{ 'nav-forward': 'fade-in', 'nav-back': 'fade-in', default: 'none' }}176 exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}177 default="none"178>179```180181**TypeScript:** `ViewTransitionClassPerType` requires a `default` key.182183### `router.back()` and Browser Back Button184185`router.back()` and the browser's back/forward buttons do **not** trigger view transitions (`popstate` is synchronous, incompatible with `startViewTransition`). Use `router.push()` with an explicit URL instead.186187### Types and Suspense188189Types are available during navigation but **not** during subsequent Suspense reveals (separate transitions, no type). Use type maps for page-level enter/exit; use simple string props for Suspense reveals.190191---192193## Shared Element Transitions194195Same `name` on two VTs — one unmounting, one mounting — creates a shared element morph:196197```jsx198<ViewTransition name="hero-image">199 <img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />200</ViewTransition>201202// Other view — same name203<ViewTransition name="hero-image">204 <img src="/full.jpg" />205</ViewTransition>206```207208- Only one VT with a given `name` can be mounted at a time — use unique names. Watch for reusable components: if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Either make the name conditional (via a prop) or move the named VT out of the shared component into the specific consumer.209- `share` takes precedence over `enter`/`exit`. Think through each navigation path: when no pair forms, `enter`/`exit` fires instead. Consider whether the element needs a fallback animation for those paths.210- Never use fade-out exit on pages with shared morphs — use directional slide.211212---213214## Common Patterns215216### Enter/Exit217218```jsx219{show && (220 <ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>221)}222```223224### List Reorder225226```jsx227{items.map(item => (228 <ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>229))}230```231232Trigger inside `startTransition`. Avoid wrapper `<div>`s between list and VT.233234### Composing Shared Elements with List Identity235236Shared elements and list identity are independent concerns — don't confuse one for the other. When a list item contains a shared element, use two nested `<ViewTransition>` boundaries:237238```jsx239{items.map(item => (240 <ViewTransition key={item.id}> {/* list identity */}241 <Link href={`/items/${item.id}`}>242 <ViewTransition name={`item-image-${item.id}`} share="morph"> {/* shared element */}243 <Image src={item.image} />244 </ViewTransition>245 <p>{item.name}</p>246 </Link>247 </ViewTransition>248))}249```250251The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.252253### Force Re-Enter with `key`254255```jsx256<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">257 <ResultsGrid />258</ViewTransition>259```260261**Caution:** Wrapping `<Suspense>` with key remounts the boundary and refetches.262263### Suspense Fallback to Content264265Simple cross-fade:266```jsx267<ViewTransition>268 <Suspense fallback={<Skeleton />}><Content /></Suspense>269</ViewTransition>270```271272Directional reveal:273```jsx274<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>275 <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>276</Suspense>277```278279---280281## How Multiple VTs Interact282283Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions don't compete.284285### Use `default="none"` Liberally286287Without it, every VT fires the browser cross-fade on **every** transition. Always use `default="none"` and explicitly enable only desired triggers.288289### Two Patterns Coexist290291**Pattern A — Directional slides:** Type-keyed VT on each page, fires during navigation.292**Pattern B — Suspense reveals:** Simple string props, fires when data loads (no type).293294They coexist because they fire at different moments. `default="none"` on both prevents cross-interference. Always pair `enter` with `exit`. Place directional VTs in page components, not layouts.295296### Nested VT Limitation297298When a parent VT exits, nested VTs inside it do **not** fire their own enter/exit — only the outermost VT animates. Per-item staggered animations during page navigation are not possible today. See [react#36135](https://github.com/facebook/react/pull/36135) for an experimental opt-in fix.299300---301302## Next.js Integration303304See the [View Transitions in Next.js](#view-transitions-in-nextjs) section below.305306---307308## Accessibility309310Always add reduced motion CSS to your global stylesheet:311312```css313@media (prefers-reduced-motion: reduce) {314 ::view-transition-old(*),315 ::view-transition-new(*),316 ::view-transition-group(*) {317 animation-duration: 0s !important;318 animation-delay: 0s !important;319 }320}321```322323---324325# Implementation Workflow326327**Follow these steps in order.** Start with the audit — do not skip it. Copy the CSS recipes from the CSS Recipes section below — do not write your own animation CSS.328329## Step 1: Audit the App330331Before writing any code, scan the codebase thoroughly. Search for:332333- **Every `<Link>` and `router.push`** — open every file that contains one334- **Every `<Suspense>` boundary** — check what its fallback renders335- **Every page/route component** — each needs a VT placement decision336- **Persistent elements** (headers, navbars, sidebars) — need `viewTransitionName` isolation337- **Shared visual elements** on both source and target views338- **Skeleton-to-content control pairs** — if a fallback renders a control that also exists in the real content, both need a matching `viewTransitionName`339340Then classify every navigation and produce a navigation map:341342```343| Route | Navigates to | Direction | VT pattern |344|-----------------|----------------------|--------------|-----------------------|345| / | /detail/[id] | forward | directional slide |346| /detail/[id] | / | back | directional slide |347| /detail/[id] | /detail/[other] | sequential | directional slide (ordered prev/next) or key+share crossfade |348| /tab/[a] | /tab/[b] | lateral | key+share crossfade |349| (Suspense) | (content loads) | — | slide-up reveal |350```351352For each shared element (`name` prop), note where a pair forms and where it doesn't — this determines whether you need `enter`/`exit` as a fallback alongside `share`.353354## Step 2: Add CSS Recipes355356Copy the **complete** CSS recipe set from the CSS Animation Recipes section below into your global stylesheet. Don't write your own — the recipes handle staggered timing, motion blur, and reduced motion.357358## Step 3: Isolate Persistent Elements359360```jsx361<header style={{ viewTransitionName: "site-header" }}>...</header>362```363364```css365::view-transition-group(site-header) {366 animation: none;367 z-index: 100;368}369```370371For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround instead.372373## Step 4: Add Directional Page Transitions374375```jsx376startTransition(() => {377 addTransitionType('nav-forward');378 router.push('/detail/1');379});380```381382Wrap each **page component** (not layout) in a type-keyed VT:383384```jsx385<ViewTransition386 enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}387 exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}388 default="none"389>390 <div>...page content...</div>391</ViewTransition>392```393394Extract into a reusable component so every page doesn't repeat the type map:395396```jsx397export function DirectionalTransition({ children }: { children: React.ReactNode }) {398 return (399 <ViewTransition400 enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}401 exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}402 default="none"403 >404 {children}405 </ViewTransition>406 );407}408```409410**Rules:** Always pair `enter` with `exit`. Always include `default: "none"`. Place in page components, not layouts. Only use directional slides for hierarchical navigation or ordered sequences (prev/next).411412## Step 5: Add Suspense Reveals413414```jsx415<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>416 <ViewTransition enter="slide-up" default="none"><AsyncContent /></ViewTransition>417</Suspense>418```419420Use `default="none"` on content VT. Use simple string props (not type maps) — Suspense resolves have no type.421422## Step 6: Add Shared Element Transitions423424```jsx425// Source view426<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">427 <Image src={photo.src} ... />428</ViewTransition>429430// Target view — same name431<ViewTransition name={`photo-${photo.id}`} share="morph">432 <Image src={photo.src} ... />433</ViewTransition>434```435436When list items contain shared elements, compose both patterns — two independent layers:437438```jsx439{items.map(item => (440 <ViewTransition key={item.id}> {/* list identity */}441 <Link href={`/detail/${item.id}`}>442 <ViewTransition name={`item-${item.id}`} share="morph" default="none"> {/* shared element */}443 <Image src={item.image} ... />444 </ViewTransition>445 </Link>446 </ViewTransition>447))}448```449450The outer VT handles list reorder/enter. The inner VT handles cross-route shared element morph. Missing either layer means that animation silently doesn't happen.451452**Rules:** Names must be globally unique. Add `default="none"` on list-side shared elements.453454## Step 7: Verify Each Navigation Path455456Walk through every row in the navigation map from Step 1:457458- Does the VT mount/unmount, or stay mounted (same-route)?459- For named VTs: does a shared pair form? If not, does `enter`/`exit` provide a fallback?460- Does `default="none"` block an animation you actually want?461- Do persistent elements stay static?462- Do Suspense reveals animate independently from directional navigations?463464---465466## Common Mistakes467468- **Bare VT without `default="none"`** — fires cross-fade on every transition469- **Directional VT in a layout** — layouts persist, enter/exit won't fire on route changes470- **Fade-out exit with shared morphs** — conflicts with morph, use directional slide471- **Writing custom animation CSS** — use the recipes472- **Missing `default: "none"` in type-keyed objects** — TypeScript requires it, fallback is `"auto"`473- **Type maps on Suspense reveals** — Suspense resolves have no type, use string props474- **Raw `viewTransitionName` CSS to trigger animations** — React only starts view transitions when `<ViewTransition>` components are in the tree. Bare `viewTransitionName` is for isolating elements, not triggering animations.475- **`update` trigger for same-route navigations** — nested VTs steal the mutation from the parent. Use `key` + `name` + `share` instead.476- **Named VT in a reusable component** — if a component with a named VT is rendered in both a modal/popover *and* a page, both mount simultaneously and break the morph. Make the name conditional or move it to the specific consumer.477- **`router.back()` for back navigation** — `router.back()` triggers synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.478479For Next.js-specific steps, see the Next.js section below.480481---482483# Patterns and Guidelines484485## Searchable Grid with `useDeferredValue`486487```tsx488'use client';489490import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';491492export default function SearchableGrid({ itemsPromise }) {493 const [search, setSearch] = useState('');494 const deferredSearch = useDeferredValue(search);495496 return (497 <>498 <input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />499 <ViewTransition>500 <Suspense fallback={<GridSkeleton />}>501 <ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />502 </Suspense>503 </ViewTransition>504 </>505 );506}507```508509Per-item named VTs in deferred lists trigger cross-fades on every keystroke. Fix with `default="none"`.510511## Card Expand/Collapse with `startTransition`512513```tsx514'use client';515516import { useState, useRef, startTransition, ViewTransition } from 'react';517518export default function ItemGrid({ items }) {519 const [expandedId, setExpandedId] = useState(null);520 const scrollRef = useRef(0);521522 return expandedId ? (523 <ViewTransition enter="slide-in" name={`item-${expandedId}`}>524 <ItemDetail525 item={items.find(i => i.id === expandedId)}526 onClose={() => {527 startTransition(() => {528 setExpandedId(null);529 setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);530 });531 }}532 />533 </ViewTransition>534 ) : (535 <div className="grid grid-cols-3 gap-4">536 {items.map(item => (537 <ViewTransition key={item.id} name={`item-${item.id}`}>538 <ItemCard539 item={item}540 onSelect={() => {541 scrollRef.current = window.scrollY;542 startTransition(() => setExpandedId(item.id));543 }}544 />545 </ViewTransition>546 ))}547 </div>548 );549}550```551552## Cross-Fade Without Remount553554Omit `key` to trigger update (cross-fade) instead of exit + enter. Avoids Suspense remount:555556```jsx557<ViewTransition><TabPanel tab={activeTab} /></ViewTransition>558```559560## Isolate Elements from Parent Animations561562Persistent elements get captured in page's transition snapshot. Fix with `viewTransitionName`:563564```jsx565<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>566```567568```css569::view-transition-group(persistent-nav) { animation: none; z-index: 100; }570```571572Same for floating elements (popovers, tooltips). Global fix: `::view-transition-group(*) { z-index: 100; }`573574## Shared Controls Between Skeleton and Content575576Give matching controls the same `viewTransitionName`. Don't put manual `viewTransitionName` on root DOM node inside `<ViewTransition>`.577578## Reusable Animated Collapse579580```jsx581function AnimatedCollapse({ open, children }) {582 if (!open) return null;583 return <ViewTransition enter="expand-in" exit="collapse-out">{children}</ViewTransition>;584}585```586587## Preserve State with Activity588589```jsx590<Activity mode={isVisible ? 'visible' : 'hidden'}>591 <ViewTransition enter="slide-in" exit="slide-out"><Sidebar /></ViewTransition>592</Activity>593```594595## Exclude Elements with `useOptimistic`596597`useOptimistic` values update before snapshot, excluding them from animation. Use for controls; use committed state for animated content.598599---600601## View Transition Events602603Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return cleanup. `onShare` takes precedence.604605```jsx606<ViewTransition607 onEnter={(instance, types) => {608 const anim = instance.new.animate(609 [{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],610 { duration: 300, easing: 'ease-out' }611 );612 return () => anim.cancel();613 }}614>615 <Component />616</ViewTransition>617```618619`instance`: `.old`, `.new`, `.group`, `.imagePair`, `.name`620621---622623## Animation Timing624625| Interaction | Duration |626|------------|----------|627| Direct toggle | 100–200ms |628| Route transition | 150–250ms |629| Suspense reveal | 200–400ms |630| Shared element morph | 300–500ms |631632---633634## Troubleshooting635636**VT not activating:** Ensure VT comes before any DOM node. Ensure `startTransition`.637638**"Two VTs with same name":** Names must be globally unique. Use IDs.639640**`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead.641642**Only updates animate:** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.643644**Layout VT prevents page VTs from animating:** Nested VTs never fire enter/exit inside a parent VT. If your layout has a VT wrapping `{children}`, page-level enter/exit will silently not work. Remove the layout VT.645646**TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.647648**Backdrop-blur flickers:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`.649650**`border-radius` lost:** Apply `border-radius` directly to captured element.651652**Batching:** Multiple updates during animation are batched (A→B→C→D becomes B→D).653654---655656# CSS Animation Recipes657658Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.659660## Timing Variables661662```css663:root {664 --duration-exit: 150ms;665 --duration-enter: 210ms;666 --duration-move: 400ms;667}668```669670### Shared Keyframes671672```css673@keyframes fade {674 from { filter: blur(3px); opacity: 0; }675 to { filter: blur(0); opacity: 1; }676}677678@keyframes slide {679 from { translate: var(--slide-offset); }680 to { translate: 0; }681}682683@keyframes slide-y {684 from { transform: translateY(var(--slide-y-offset, 10px)); }685 to { transform: translateY(0); }686}687```688689## Fade690691```css692::view-transition-old(.fade-out) {693 animation: var(--duration-exit) ease-in fade reverse;694}695::view-transition-new(.fade-in) {696 animation: var(--duration-enter) ease-out var(--duration-exit) both fade;697}698```699700## Slide (Vertical)701702```css703::view-transition-old(.slide-down) {704 animation:705 var(--duration-exit) ease-out both fade reverse,706 var(--duration-exit) ease-out both slide-y reverse;707}708::view-transition-new(.slide-up) {709 animation:710 var(--duration-enter) ease-in var(--duration-exit) both fade,711 var(--duration-move) ease-in both slide-y;712}713```714715## Directional Navigation716717### Single-Class Approach718719```css720::view-transition-old(.nav-forward) {721 --slide-offset: -60px;722 animation:723 var(--duration-exit) ease-in both fade reverse,724 var(--duration-move) ease-in-out both slide reverse;725}726::view-transition-new(.nav-forward) {727 --slide-offset: 60px;728 animation:729 var(--duration-enter) ease-out var(--duration-exit) both fade,730 var(--duration-move) ease-in-out both slide;731}732733::view-transition-old(.nav-back) {734 --slide-offset: 60px;735 animation:736 var(--duration-exit) ease-in both fade reverse,737 var(--duration-move) ease-in-out both slide reverse;738}739::view-transition-new(.nav-back) {740 --slide-offset: -60px;741 animation:742 var(--duration-enter) ease-out var(--duration-exit) both fade,743 var(--duration-move) ease-in-out both slide;744}745```746747### Separate Enter/Exit Classes748749```css750::view-transition-new(.slide-from-right) {751 --slide-offset: 60px;752 animation:753 var(--duration-enter) ease-out var(--duration-exit) both fade,754 var(--duration-move) ease-in-out both slide;755}756::view-transition-old(.slide-to-left) {757 --slide-offset: -60px;758 animation:759 var(--duration-exit) ease-in both fade reverse,760 var(--duration-move) ease-in-out both slide reverse;761}762763::view-transition-new(.slide-from-left) {764 --slide-offset: -60px;765 animation:766 var(--duration-enter) ease-out var(--duration-exit) both fade,767 var(--duration-move) ease-in-out both slide;768}769::view-transition-old(.slide-to-right) {770 --slide-offset: 60px;771 animation:772 var(--duration-exit) ease-in both fade reverse,773 var(--duration-move) ease-in-out both slide reverse;774}775```776777## Shared Element Morph778779```css780::view-transition-group(.morph) {781 animation-duration: var(--duration-move);782}783::view-transition-image-pair(.morph) {784 animation-name: via-blur;785}786@keyframes via-blur {787 30% { filter: blur(3px); }788}789```790791**Note:** Shared element transitions take raster snapshots. For text with significant size differences (e.g., `<h3>` → `<h1>`), the old snapshot gets scaled up, producing a visible ghost artifact. Use `text-morph` for text shared elements.792793## Text Morph794795Avoids raster scaling artifacts on text by hiding the old snapshot and showing the new text at full resolution:796797```css798::view-transition-group(.text-morph) {799 animation-duration: var(--duration-move);800}801::view-transition-old(.text-morph) {802 display: none;803}804::view-transition-new(.text-morph) {805 animation: none;806 object-fit: none;807 object-position: left top;808}809```810811## Scale812813```css814::view-transition-old(.scale-out) {815 animation: var(--duration-exit) ease-in scale-down;816}817::view-transition-new(.scale-in) {818 animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;819}820@keyframes scale-down {821 from { transform: scale(1); opacity: 1; }822 to { transform: scale(0.85); opacity: 0; }823}824@keyframes scale-up {825 from { transform: scale(0.85); opacity: 0; }826 to { transform: scale(1); opacity: 1; }827}828```829830## Persistent Element Isolation831832```css833::view-transition-group(persistent-nav) {834 animation: none;835 z-index: 100;836}837```838839### Backdrop-Blur Workaround840841```css842::view-transition-old(persistent-nav) { display: none; }843::view-transition-new(persistent-nav) { animation: none; }844```845846## Reduced Motion847848```css849@media (prefers-reduced-motion: reduce) {850 ::view-transition-old(*),851 ::view-transition-new(*),852 ::view-transition-group(*) {853 animation-duration: 0s !important;854 animation-delay: 0s !important;855 }856}857```858859---860861# View Transitions in Next.js862863## Setup864865```js866// next.config.js867experimental: { viewTransition: true }868```869870Wraps every `<Link>` navigation in `document.startViewTransition`. Use `default="none"` to prevent competing animations. Do **not** install `react@canary` — the App Router already bundles it.871872## Next.js Implementation Additions873874**After Step 2:** Enable the experimental flag.875876**Step 4:** Use `transitionTypes` on `<Link>` (if available — see availability note below):877```tsx878<Link href="/photo/1" transitionTypes={["nav-forward"]}>View</Link>879<Link href="/" transitionTypes={["nav-back"]}>Back</Link>880```881882**After Step 6:** For same-route dynamic segments, use `key` + `name` + `share` pattern.883884## Layout-Level ViewTransition885886Don't add a layout-level VT wrapping `{children}` if pages have their own VTs — nested VTs never fire enter/exit inside a parent VT, so page-level enter/exit will silently not work. Remove the layout VT entirely. A bare VT in layout works only if pages have no VTs of their own. Layouts persist across navigations — don't use type-keyed maps in layouts.887888## The `transitionTypes` Prop889890Works in Server Components, no wrapper needed:891```tsx892<Link href="/products/1" transitionTypes={['nav-forward']}>View</Link>893```894895**Availability:** Requires `experimental.viewTransition: true`. Available in Next.js 15+ canary builds and Next.js 16+. If unavailable, use `startTransition` + `addTransitionType` + `router.push()`. To check: `grep -r "transitionTypes" node_modules/next/dist/`. Reserve manual `startTransition` for non-link interactions.896897## `loading.tsx` as Suspense Boundary898899Next.js `loading.tsx` files are implicit `<Suspense>` boundaries. Wrap the skeleton in `<ViewTransition exit="...">` in `loading.tsx`, and the content in `<ViewTransition enter="..." default="none">` in the page. This is the Next.js-idiomatic equivalent of explicit `<Suspense fallback={...}>`. Same rules apply: use simple string props (not type maps) since Suspense reveals fire without transition types.900901## Server-Side Filtering with `router.replace`902903For search/sort/filter that re-renders on the server (via URL params), use `startTransition` + `router.replace`. VTs activate because the update is inside `startTransition`. List items wrapped in `<ViewTransition key={item.id}>` animate reorder. This is the server-component alternative to the client-side `useDeferredValue` pattern.904905## Two-Layer Pattern (Directional + Suspense)906907Directional slides + Suspense reveals coexist because they fire at different moments. Place the directional VT in the **page component** (not layout):908909```tsx910<ViewTransition911 enter={{ "nav-forward": "slide-from-right", default: "none" }}912 exit={{ "nav-forward": "slide-to-left", default: "none" }}913 default="none"914>915 <div>916 <Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>917 <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>918 </Suspense>919 </div>920</ViewTransition>921```922923## Shared Elements Across Routes924925```tsx926// List page927<Link href={`/products/${product.id}`} transitionTypes={['nav-forward']}>928 <ViewTransition name={`product-${product.id}`}>929 <Image src={product.image} alt={product.name} width={400} height={300} />930 </ViewTransition>931</Link>932933// Detail page — same name934<ViewTransition name={`product-${product.id}`}>935 <Image src={product.image} alt={product.name} width={800} height={600} />936</ViewTransition>937```938939## Same-Route Dynamic Segment Transitions940941Page stays mounted on dynamic segment change — enter/exit never fire. Use `key` + `name` + `share`:942943```tsx944<Suspense fallback={<Skeleton />}>945 <ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">946 <Content slug={slug} />947 </ViewTransition>948</Suspense>949```950951## Server Components952953- `<ViewTransition>` works in Server and Client Components954- `<Link transitionTypes>` works in Server Components955- `addTransitionType` and programmatic nav require Client Components956
One repository carrying more than one format is the comparison this product exists for: does anyone actually write different content in each file, or is one a copy of the other?
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-build.mdc · 51 | Cursor rules | no sections | 16/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-code-simplify.mdc · 51 | Cursor rules | testing-strategy | 30/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-plan.mdc · 51 | Cursor rules | no sections | 16/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-review.mdc · 51 | Cursor rules | no sections | 16/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-ship.mdc · 51 | Cursor rules | testing-strategygitdeploymentdo-not | 61/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-spec.mdc · 51 | Cursor rules | no sections | 16/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-cursor/.cursor/rules/cmd-test.mdc · 51 | Cursor rules | no sections | 16/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-forgecat/AGENTS.md · 51 | AGENTS.md | lint-formatstylearchdo-not | 73/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/addyosmani/agent-skills/for-forgecat/CLAUDE.md · 51 | CLAUDE.md | teststylearchagent-behaviour | 70/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-code/anthropics_claude-code_ralph-wiggum/for-cursor/.cursor/rules/cmd-cancel-ralph.mdc · 51 | Cursor rules | no sections | 16/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-code/anthropics_claude-code_ralph-wiggum/for-cursor/.cursor/rules/cmd-help.mdc · 51 | Cursor rules | no sections | 54/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-code/anthropics_claude-code_ralph-wiggum/for-cursor/.cursor/rules/cmd-ralph-loop.mdc · 51 | Cursor rules | no sections | 22/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_agent-sdk-dev/for-cursor/.cursor/rules/cmd-new-sdk-app.mdc · 51 | Cursor rules | setupstylearchdocs | 76/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_claude-md-management/for-cursor/.cursor/rules/cmd-revise-claude-md.mdc · 51 | Cursor rules | agent-behaviour | 50/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_code-review/for-cursor/.cursor/rules/cmd-code-review.mdc · 51 | Cursor rules | testing-strategygit | 35/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_commit-commands/for-cursor/.cursor/rules/cmd-clean_gone.mdc · 51 | Cursor rules | no sections | 60/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_commit-commands/for-cursor/.cursor/rules/cmd-commit-push-pr.mdc · 51 | Cursor rules | stylegit | 44/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_commit-commands/for-cursor/.cursor/rules/cmd-commit.mdc · 51 | Cursor rules | style | 44/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_example-plugin/for-cursor/.cursor/rules/cmd-example-command.mdc · 51 | Cursor rules | lint-formatstyleagent-behaviour | 58/100 | today | |
| nota-america/forgecat-agent-profilesprofiles/anthropics/claude-plugins-official/anthropics_claude-plugins-official_feature-dev/for-cursor/.cursor/rules/cmd-feature-dev.mdc · 51 | Cursor rules | stylearchgit | 56/100 | today |
Same format, overlapping stack, ranked by quality.
| Repository | Format | Stack | Covers | Score | Changed |
|---|---|---|---|---|---|
| n8n-io/n8npackages/@n8n/agents/AGENTS.md · 201k | AGENTS.md | buildteststylearch+3 | 100/100 | 14 days ago | |
| aaif-goose/gooseAGENTS.md · 53k | AGENTS.md | setupbuildtestlint-format+7 | 100/100 | 8 days ago | |
| duckduckgo/content-scope-scriptsspecial-pages/AGENTS.md · 70 | AGENTS.md | buildteststylearch+3 | 100/100 | 14 days ago | |
| deepseek-ai/deepseek-harnessnative/landlock-run/AGENTS.md · 104k | AGENTS.md | setupteststylearch+3 | 100/100 | today | |
| code-yeongyu/oh-my-openagentpackages/web/AGENTS.md · 68k | AGENTS.md | setupbuildtestlint-format+6 | 100/100 | 13 days ago | |
| TryGhost/Ghoste2e/AGENTS.md · 55k | AGENTS.md | setupteststylearch+2 | 100/100 | today | |
| vllm-project/vllmAGENTS.md · 89k | AGENTS.md | setuptestlint-formatstyle+5 | 100/100 | 14 days ago | |
| mui/material-uiAGENTS.md · 99k | AGENTS.md | setupbuildtestlint-format+9 | 100/100 | 14 days ago |
A badge carrying the measured quality of the strongest agent config file in this repository, out of 100. It reads from this index every time somebody loads your page, so it changes when the measurement changes and there is nothing to keep up to date. Free, no account, and the value is not something you or we can set by hand.
[](https://rulestack.kynth.studio/configs/nota-america-forgecat-agent-profiles-profiles-vercel-labs-agent-skills-vercel-labs-agent-skills-react-view-transitions-for-forgecat-agents)Would rather not hotlink us? Every badge is also served in shields.io’s endpoint schema, so shields renders the image and your readers never talk to our domain:
Published by Toolproof, the masthead over this index and eight others. The method behind the number is at toolproof.kynth.studio/methodology, and the whole thing is readable as JSON with no key at /api.