

Also from Kynth Studios


Also from Kynth Studios


Also from Kynth Studios
1<!-- forgecat:@forgecat/vercel-labs_agent-skills_react-view-transitions:react-view-transitions-agents:start -->2# React View Transitions34**Version 1.0.0**5Vercel Engineering6March 202678> **Note:**9> This document is mainly for agents and LLMs to follow when implementing10> view transitions in React applications. Humans may also find it useful,11> but guidance here is optimized for automation and consistency by12> AI-assisted workflows.1314---1516## Abstract1718Guide 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.1920---2122## Table of Contents23241. [Core Reference](#when-to-animate)25 - [When to Animate](#when-to-animate)26 - [Availability](#availability)27 - [Core Concepts](#core-concepts)28 - [Styling with View Transition Classes](#styling-with-view-transition-classes)29 - [Transition Types](#transition-types)30 - [Shared Element Transitions](#shared-element-transitions)31 - [Common Patterns](#common-patterns)32 - [How Multiple VTs Interact](#how-multiple-vts-interact)33 - [Next.js Integration](#nextjs-integration)34 - [Accessibility](#accessibility)352. [Implementation Workflow](#implementation-workflow)36 - [Step 1: Audit the App](#step-1-audit-the-app)37 - [Step 2: Add CSS Recipes](#step-2-add-css-recipes)38 - [Step 3: Isolate Persistent Elements](#step-3-isolate-persistent-elements)39 - [Step 4: Add Directional Page Transitions](#step-4-add-directional-page-transitions)40 - [Step 5: Add Suspense Reveals](#step-5-add-suspense-reveals)41 - [Step 6: Add Shared Element Transitions](#step-6-add-shared-element-transitions)42 - [Step 7: Verify Each Navigation Path](#step-7-verify-each-navigation-path)43 - [Common Mistakes](#common-mistakes)443. [Patterns and Guidelines](#patterns-and-guidelines)454. [CSS Animation Recipes](#css-animation-recipes)465. [View Transitions in Next.js](#view-transitions-in-nextjs)4748---4950Animate 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.5152## When to Animate5354Every `<ViewTransition>` should communicate a spatial relationship or continuity. If you can't articulate what it communicates, don't add it.5556Implement **all** applicable patterns from this list, in this order:5758| Priority | Pattern | What it communicates |59|----------|---------|---------------------|60| 1 | **Shared element** (`name`) | "Same thing — going deeper" |61| 2 | **Suspense reveal** | "Data loaded" |62| 3 | **List identity** (per-item `key`) | "Same items, new arrangement" |63| 4 | **State change** (`enter`/`exit`) | "Something appeared/disappeared" |64| 5 | **Route change** (layout-level) | "Going to a new place" |6566This 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.6768### Choosing Animation Style6970| Context | Animation | Why |71|---------|-----------|-----|72| Hierarchical navigation (list → detail) | Type-keyed `nav-forward` / `nav-back` | Communicates spatial depth |73| Lateral navigation (tab-to-tab) | Bare `<ViewTransition>` (fade) or `default="none"` | No depth to communicate |74| Suspense reveal | `enter`/`exit` string props | Content arriving |75| Revalidation / background refresh | `default="none"` | Silent — no animation needed |7677Reserve 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.7879---8081## Availability8283- **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.84- **Without Next.js:** Install `react@canary react-dom@canary` (`ViewTransition` is not in stable React).85- Browser support: Chromium 111+, Firefox 144+, Safari 18.2+. Graceful degradation.8687---8889## Core Concepts9091### The `<ViewTransition>` Component9293```jsx94import { ViewTransition } from 'react';9596<ViewTransition>97 <Component />98</ViewTransition>99```100101React auto-assigns a unique `view-transition-name` and calls `document.startViewTransition` behind the scenes. Never call `startViewTransition` yourself.102103### Animation Triggers104105| Trigger | When it fires |106|---------|--------------|107| **enter** | VT first inserted during a Transition |108| **exit** | VT first removed during a Transition |109| **update** | DOM mutations inside a VT. With nested VTs, mutation applies to the innermost one |110| **share** | Named VT unmounts and another with same `name` mounts in same Transition |111112Only `startTransition`, `useDeferredValue`, or `Suspense` activate VTs. Regular `setState` does not animate.113114### Critical Placement Rule115116VT only activates enter/exit if it appears **before any DOM nodes**:117118```jsx119// Works120<ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition>121122// Broken — div wraps the VT123<div><ViewTransition enter="auto" exit="auto"><div>Content</div></ViewTransition></div>124```125126---127128## Styling with View Transition Classes129130Values: `"auto"` (browser cross-fade), `"none"` (disabled), `"class-name"` (custom CSS), or `{ [type]: value }` for type-specific animations.131132```jsx133<ViewTransition default="none" enter="slide-in" exit="slide-out" share="morph" />134```135136If `default` is `"none"`, all triggers are off unless explicitly listed.137138### CSS Pseudo-Elements139140- `::view-transition-old(.class)` — outgoing snapshot141- `::view-transition-new(.class)` — incoming snapshot142- `::view-transition-group(.class)` — container143- `::view-transition-image-pair(.class)` — old + new pair144145---146147## Transition Types148149Tag 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:150151```jsx152startTransition(() => {153 addTransitionType('nav-forward');154 addTransitionType('select-item');155 router.push('/detail/1');156});157```158159Map types to CSS classes. Works on `enter`, `exit`, **and** `share`:160161```jsx162<ViewTransition163 enter={{ 'nav-forward': 'slide-from-right', 'nav-back': 'slide-from-left', default: 'none' }}164 exit={{ 'nav-forward': 'slide-to-left', 'nav-back': 'slide-to-right', default: 'none' }}165 share={{ 'nav-forward': 'morph-forward', 'nav-back': 'morph-back', default: 'morph' }}166 default="none"167>168 <Page />169</ViewTransition>170```171172`enter` and `exit` don't have to be symmetric. For example, fade in but slide out directionally:173174```jsx175<ViewTransition176 enter={{ 'nav-forward': 'fade-in', 'nav-back': 'fade-in', default: 'none' }}177 exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}178 default="none"179>180```181182**TypeScript:** `ViewTransitionClassPerType` requires a `default` key.183184### `router.back()` and Browser Back Button185186`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.187188### Types and Suspense189190Types 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.191192---193194## Shared Element Transitions195196Same `name` on two VTs — one unmounting, one mounting — creates a shared element morph:197198```jsx199<ViewTransition name="hero-image">200 <img src="/thumb.jpg" onClick={() => startTransition(() => onSelect())} />201</ViewTransition>202203// Other view — same name204<ViewTransition name="hero-image">205 <img src="/full.jpg" />206</ViewTransition>207```208209- 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.210- `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.211- Never use fade-out exit on pages with shared morphs — use directional slide.212213---214215## Common Patterns216217### Enter/Exit218219```jsx220{show && (221 <ViewTransition enter="fade-in" exit="fade-out"><Panel /></ViewTransition>222)}223```224225### List Reorder226227```jsx228{items.map(item => (229 <ViewTransition key={item.id}><ItemCard item={item} /></ViewTransition>230))}231```232233Trigger inside `startTransition`. Avoid wrapper `<div>`s between list and VT.234235### Composing Shared Elements with List Identity236237Shared 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:238239```jsx240{items.map(item => (241 <ViewTransition key={item.id}> {/* list identity */}242 <Link href={`/items/${item.id}`}>243 <ViewTransition name={`item-image-${item.id}`} share="morph"> {/* shared element */}244 <Image src={item.image} />245 </ViewTransition>246 <p>{item.name}</p>247 </Link>248 </ViewTransition>249))}250```251252The 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.253254### Force Re-Enter with `key`255256```jsx257<ViewTransition key={searchParams.toString()} enter="slide-up" default="none">258 <ResultsGrid />259</ViewTransition>260```261262**Caution:** Wrapping `<Suspense>` with key remounts the boundary and refetches.263264### Suspense Fallback to Content265266Simple cross-fade:267```jsx268<ViewTransition>269 <Suspense fallback={<Skeleton />}><Content /></Suspense>270</ViewTransition>271```272273Directional reveal:274```jsx275<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>276 <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>277</Suspense>278```279280---281282## How Multiple VTs Interact283284Every VT matching the trigger fires simultaneously in a single `document.startViewTransition`. VTs in **different** transitions don't compete.285286### Use `default="none"` Liberally287288Without it, every VT fires the browser cross-fade on **every** transition. Always use `default="none"` and explicitly enable only desired triggers.289290### Two Patterns Coexist291292**Pattern A — Directional slides:** Type-keyed VT on each page, fires during navigation.293**Pattern B — Suspense reveals:** Simple string props, fires when data loads (no type).294295They 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.296297### Nested VT Limitation298299When 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.300301---302303## Next.js Integration304305See the [View Transitions in Next.js](#view-transitions-in-nextjs) section below.306307---308309## Accessibility310311Always add reduced motion CSS to your global stylesheet:312313```css314@media (prefers-reduced-motion: reduce) {315 ::view-transition-old(*),316 ::view-transition-new(*),317 ::view-transition-group(*) {318 animation-duration: 0s !important;319 animation-delay: 0s !important;320 }321}322```323324---325326# Implementation Workflow327328**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.329330## Step 1: Audit the App331332Before writing any code, scan the codebase thoroughly. Search for:333334- **Every `<Link>` and `router.push`** — open every file that contains one335- **Every `<Suspense>` boundary** — check what its fallback renders336- **Every page/route component** — each needs a VT placement decision337- **Persistent elements** (headers, navbars, sidebars) — need `viewTransitionName` isolation338- **Shared visual elements** on both source and target views339- **Skeleton-to-content control pairs** — if a fallback renders a control that also exists in the real content, both need a matching `viewTransitionName`340341Then classify every navigation and produce a navigation map:342343```344| Route | Navigates to | Direction | VT pattern |345|-----------------|----------------------|--------------|-----------------------|346| / | /detail/[id] | forward | directional slide |347| /detail/[id] | / | back | directional slide |348| /detail/[id] | /detail/[other] | sequential | directional slide (ordered prev/next) or key+share crossfade |349| /tab/[a] | /tab/[b] | lateral | key+share crossfade |350| (Suspense) | (content loads) | — | slide-up reveal |351```352353For 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`.354355## Step 2: Add CSS Recipes356357Copy 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.358359## Step 3: Isolate Persistent Elements360361```jsx362<header style={{ viewTransitionName: "site-header" }}>...</header>363```364365```css366::view-transition-group(site-header) {367 animation: none;368 z-index: 100;369}370```371372For `backdrop-blur`/`backdrop-filter`, use the backdrop-blur workaround instead.373374## Step 4: Add Directional Page Transitions375376```jsx377startTransition(() => {378 addTransitionType('nav-forward');379 router.push('/detail/1');380});381```382383Wrap each **page component** (not layout) in a type-keyed VT:384385```jsx386<ViewTransition387 enter={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}388 exit={{ "nav-forward": "nav-forward", "nav-back": "nav-back", default: "none" }}389 default="none"390>391 <div>...page content...</div>392</ViewTransition>393```394395Extract into a reusable component so every page doesn't repeat the type map:396397```jsx398export function DirectionalTransition({ children }: { children: React.ReactNode }) {399 return (400 <ViewTransition401 enter={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}402 exit={{ 'nav-forward': 'nav-forward', 'nav-back': 'nav-back', default: 'none' }}403 default="none"404 >405 {children}406 </ViewTransition>407 );408}409```410411**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).412413## Step 5: Add Suspense Reveals414415```jsx416<Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>417 <ViewTransition enter="slide-up" default="none"><AsyncContent /></ViewTransition>418</Suspense>419```420421Use `default="none"` on content VT. Use simple string props (not type maps) — Suspense resolves have no type.422423## Step 6: Add Shared Element Transitions424425```jsx426// Source view427<ViewTransition name={`photo-${photo.id}`} share="morph" default="none">428 <Image src={photo.src} ... />429</ViewTransition>430431// Target view — same name432<ViewTransition name={`photo-${photo.id}`} share="morph">433 <Image src={photo.src} ... />434</ViewTransition>435```436437When list items contain shared elements, compose both patterns — two independent layers:438439```jsx440{items.map(item => (441 <ViewTransition key={item.id}> {/* list identity */}442 <Link href={`/detail/${item.id}`}>443 <ViewTransition name={`item-${item.id}`} share="morph" default="none"> {/* shared element */}444 <Image src={item.image} ... />445 </ViewTransition>446 </Link>447 </ViewTransition>448))}449```450451The 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.452453**Rules:** Names must be globally unique. Add `default="none"` on list-side shared elements.454455## Step 7: Verify Each Navigation Path456457Walk through every row in the navigation map from Step 1:458459- Does the VT mount/unmount, or stay mounted (same-route)?460- For named VTs: does a shared pair form? If not, does `enter`/`exit` provide a fallback?461- Does `default="none"` block an animation you actually want?462- Do persistent elements stay static?463- Do Suspense reveals animate independently from directional navigations?464465---466467## Common Mistakes468469- **Bare VT without `default="none"`** — fires cross-fade on every transition470- **Directional VT in a layout** — layouts persist, enter/exit won't fire on route changes471- **Fade-out exit with shared morphs** — conflicts with morph, use directional slide472- **Writing custom animation CSS** — use the recipes473- **Missing `default: "none"` in type-keyed objects** — TypeScript requires it, fallback is `"auto"`474- **Type maps on Suspense reveals** — Suspense resolves have no type, use string props475- **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.476- **`update` trigger for same-route navigations** — nested VTs steal the mutation from the parent. Use `key` + `name` + `share` instead.477- **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.478- **`router.back()` for back navigation** — `router.back()` triggers synchronous `popstate`, incompatible with view transitions. Use `router.push()` with an explicit URL.479480For Next.js-specific steps, see the Next.js section below.481482---483484# Patterns and Guidelines485486## Searchable Grid with `useDeferredValue`487488```tsx489'use client';490491import { useDeferredValue, useState, ViewTransition, Suspense } from 'react';492493export default function SearchableGrid({ itemsPromise }) {494 const [search, setSearch] = useState('');495 const deferredSearch = useDeferredValue(search);496497 return (498 <>499 <input value={search} onChange={(e) => setSearch(e.currentTarget.value)} />500 <ViewTransition>501 <Suspense fallback={<GridSkeleton />}>502 <ItemGrid itemsPromise={itemsPromise} search={deferredSearch} />503 </Suspense>504 </ViewTransition>505 </>506 );507}508```509510Per-item named VTs in deferred lists trigger cross-fades on every keystroke. Fix with `default="none"`.511512## Card Expand/Collapse with `startTransition`513514```tsx515'use client';516517import { useState, useRef, startTransition, ViewTransition } from 'react';518519export default function ItemGrid({ items }) {520 const [expandedId, setExpandedId] = useState(null);521 const scrollRef = useRef(0);522523 return expandedId ? (524 <ViewTransition enter="slide-in" name={`item-${expandedId}`}>525 <ItemDetail526 item={items.find(i => i.id === expandedId)}527 onClose={() => {528 startTransition(() => {529 setExpandedId(null);530 setTimeout(() => window.scrollTo({ behavior: 'smooth', top: scrollRef.current }), 100);531 });532 }}533 />534 </ViewTransition>535 ) : (536 <div className="grid grid-cols-3 gap-4">537 {items.map(item => (538 <ViewTransition key={item.id} name={`item-${item.id}`}>539 <ItemCard540 item={item}541 onSelect={() => {542 scrollRef.current = window.scrollY;543 startTransition(() => setExpandedId(item.id));544 }}545 />546 </ViewTransition>547 ))}548 </div>549 );550}551```552553## Cross-Fade Without Remount554555Omit `key` to trigger update (cross-fade) instead of exit + enter. Avoids Suspense remount:556557```jsx558<ViewTransition><TabPanel tab={activeTab} /></ViewTransition>559```560561## Isolate Elements from Parent Animations562563Persistent elements get captured in page's transition snapshot. Fix with `viewTransitionName`:564565```jsx566<nav style={{ viewTransitionName: "persistent-nav" }}>{/* ... */}</nav>567```568569```css570::view-transition-group(persistent-nav) { animation: none; z-index: 100; }571```572573Same for floating elements (popovers, tooltips). Global fix: `::view-transition-group(*) { z-index: 100; }`574575## Shared Controls Between Skeleton and Content576577Give matching controls the same `viewTransitionName`. Don't put manual `viewTransitionName` on root DOM node inside `<ViewTransition>`.578579## Reusable Animated Collapse580581```jsx582function AnimatedCollapse({ open, children }) {583 if (!open) return null;584 return <ViewTransition enter="expand-in" exit="collapse-out">{children}</ViewTransition>;585}586```587588## Preserve State with Activity589590```jsx591<Activity mode={isVisible ? 'visible' : 'hidden'}>592 <ViewTransition enter="slide-in" exit="slide-out"><Sidebar /></ViewTransition>593</Activity>594```595596## Exclude Elements with `useOptimistic`597598`useOptimistic` values update before snapshot, excluding them from animation. Use for controls; use committed state for animated content.599600---601602## View Transition Events603604Imperative control via `onEnter`, `onExit`, `onUpdate`, `onShare`. Always return cleanup. `onShare` takes precedence.605606```jsx607<ViewTransition608 onEnter={(instance, types) => {609 const anim = instance.new.animate(610 [{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],611 { duration: 300, easing: 'ease-out' }612 );613 return () => anim.cancel();614 }}615>616 <Component />617</ViewTransition>618```619620`instance`: `.old`, `.new`, `.group`, `.imagePair`, `.name`621622---623624## Animation Timing625626| Interaction | Duration |627|------------|----------|628| Direct toggle | 100–200ms |629| Route transition | 150–250ms |630| Suspense reveal | 200–400ms |631| Shared element morph | 300–500ms |632633---634635## Troubleshooting636637**VT not activating:** Ensure VT comes before any DOM node. Ensure `startTransition`.638639**"Two VTs with same name":** Names must be globally unique. Use IDs.640641**`router.back()` and browser back/forward skip animation:** Use `router.push()` with an explicit URL instead.642643**Only updates animate:** Without `<Suspense>`, React treats swaps as updates. Conditionally render the VT itself, or wrap in `<Suspense>`.644645**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.646647**TS error "Property 'default' is missing":** Type-keyed objects require a `default` key.648649**Backdrop-blur flickers:** `::view-transition-old(name) { display: none }` + `::view-transition-new(name) { animation: none }`.650651**`border-radius` lost:** Apply `border-radius` directly to captured element.652653**Batching:** Multiple updates during animation are batched (A→B→C→D becomes B→D).654655---656657# CSS Animation Recipes658659Ready-to-use CSS for `<ViewTransition>` props. Copy into global stylesheet.660661## Timing Variables662663```css664:root {665 --duration-exit: 150ms;666 --duration-enter: 210ms;667 --duration-move: 400ms;668}669```670671### Shared Keyframes672673```css674@keyframes fade {675 from { filter: blur(3px); opacity: 0; }676 to { filter: blur(0); opacity: 1; }677}678679@keyframes slide {680 from { translate: var(--slide-offset); }681 to { translate: 0; }682}683684@keyframes slide-y {685 from { transform: translateY(var(--slide-y-offset, 10px)); }686 to { transform: translateY(0); }687}688```689690## Fade691692```css693::view-transition-old(.fade-out) {694 animation: var(--duration-exit) ease-in fade reverse;695}696::view-transition-new(.fade-in) {697 animation: var(--duration-enter) ease-out var(--duration-exit) both fade;698}699```700701## Slide (Vertical)702703```css704::view-transition-old(.slide-down) {705 animation:706 var(--duration-exit) ease-out both fade reverse,707 var(--duration-exit) ease-out both slide-y reverse;708}709::view-transition-new(.slide-up) {710 animation:711 var(--duration-enter) ease-in var(--duration-exit) both fade,712 var(--duration-move) ease-in both slide-y;713}714```715716## Directional Navigation717718### Single-Class Approach719720```css721::view-transition-old(.nav-forward) {722 --slide-offset: -60px;723 animation:724 var(--duration-exit) ease-in both fade reverse,725 var(--duration-move) ease-in-out both slide reverse;726}727::view-transition-new(.nav-forward) {728 --slide-offset: 60px;729 animation:730 var(--duration-enter) ease-out var(--duration-exit) both fade,731 var(--duration-move) ease-in-out both slide;732}733734::view-transition-old(.nav-back) {735 --slide-offset: 60px;736 animation:737 var(--duration-exit) ease-in both fade reverse,738 var(--duration-move) ease-in-out both slide reverse;739}740::view-transition-new(.nav-back) {741 --slide-offset: -60px;742 animation:743 var(--duration-enter) ease-out var(--duration-exit) both fade,744 var(--duration-move) ease-in-out both slide;745}746```747748### Separate Enter/Exit Classes749750```css751::view-transition-new(.slide-from-right) {752 --slide-offset: 60px;753 animation:754 var(--duration-enter) ease-out var(--duration-exit) both fade,755 var(--duration-move) ease-in-out both slide;756}757::view-transition-old(.slide-to-left) {758 --slide-offset: -60px;759 animation:760 var(--duration-exit) ease-in both fade reverse,761 var(--duration-move) ease-in-out both slide reverse;762}763764::view-transition-new(.slide-from-left) {765 --slide-offset: -60px;766 animation:767 var(--duration-enter) ease-out var(--duration-exit) both fade,768 var(--duration-move) ease-in-out both slide;769}770::view-transition-old(.slide-to-right) {771 --slide-offset: 60px;772 animation:773 var(--duration-exit) ease-in both fade reverse,774 var(--duration-move) ease-in-out both slide reverse;775}776```777778## Shared Element Morph779780```css781::view-transition-group(.morph) {782 animation-duration: var(--duration-move);783}784::view-transition-image-pair(.morph) {785 animation-name: via-blur;786}787@keyframes via-blur {788 30% { filter: blur(3px); }789}790```791792**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.793794## Text Morph795796Avoids raster scaling artifacts on text by hiding the old snapshot and showing the new text at full resolution:797798```css799::view-transition-group(.text-morph) {800 animation-duration: var(--duration-move);801}802::view-transition-old(.text-morph) {803 display: none;804}805::view-transition-new(.text-morph) {806 animation: none;807 object-fit: none;808 object-position: left top;809}810```811812## Scale813814```css815::view-transition-old(.scale-out) {816 animation: var(--duration-exit) ease-in scale-down;817}818::view-transition-new(.scale-in) {819 animation: var(--duration-enter) ease-out var(--duration-exit) both scale-up;820}821@keyframes scale-down {822 from { transform: scale(1); opacity: 1; }823 to { transform: scale(0.85); opacity: 0; }824}825@keyframes scale-up {826 from { transform: scale(0.85); opacity: 0; }827 to { transform: scale(1); opacity: 1; }828}829```830831## Persistent Element Isolation832833```css834::view-transition-group(persistent-nav) {835 animation: none;836 z-index: 100;837}838```839840### Backdrop-Blur Workaround841842```css843::view-transition-old(persistent-nav) { display: none; }844::view-transition-new(persistent-nav) { animation: none; }845```846847## Reduced Motion848849```css850@media (prefers-reduced-motion: reduce) {851 ::view-transition-old(*),852 ::view-transition-new(*),853 ::view-transition-group(*) {854 animation-duration: 0s !important;855 animation-delay: 0s !important;856 }857}858```859860---861862# View Transitions in Next.js863864## Setup865866```js867// next.config.js868experimental: { viewTransition: true }869```870871Wraps every `<Link>` navigation in `document.startViewTransition`. Use `default="none"` to prevent competing animations. Do **not** install `react@canary` — the App Router already bundles it.872873## Next.js Implementation Additions874875**After Step 2:** Enable the experimental flag.876877**Step 4:** Use `transitionTypes` on `<Link>` (if available — see availability note below):878```tsx879<Link href="/photo/1" transitionTypes={["nav-forward"]}>View</Link>880<Link href="/" transitionTypes={["nav-back"]}>Back</Link>881```882883**After Step 6:** For same-route dynamic segments, use `key` + `name` + `share` pattern.884885## Layout-Level ViewTransition886887Don'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.888889## The `transitionTypes` Prop890891Works in Server Components, no wrapper needed:892```tsx893<Link href="/products/1" transitionTypes={['nav-forward']}>View</Link>894```895896**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.897898## `loading.tsx` as Suspense Boundary899900Next.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.901902## Server-Side Filtering with `router.replace`903904For 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.905906## Two-Layer Pattern (Directional + Suspense)907908Directional slides + Suspense reveals coexist because they fire at different moments. Place the directional VT in the **page component** (not layout):909910```tsx911<ViewTransition912 enter={{ "nav-forward": "slide-from-right", default: "none" }}913 exit={{ "nav-forward": "slide-to-left", default: "none" }}914 default="none"915>916 <div>917 <Suspense fallback={<ViewTransition exit="slide-down"><Skeleton /></ViewTransition>}>918 <ViewTransition enter="slide-up" default="none"><Content /></ViewTransition>919 </Suspense>920 </div>921</ViewTransition>922```923924## Shared Elements Across Routes925926```tsx927// List page928<Link href={`/products/${product.id}`} transitionTypes={['nav-forward']}>929 <ViewTransition name={`product-${product.id}`}>930 <Image src={product.image} alt={product.name} width={400} height={300} />931 </ViewTransition>932</Link>933934// Detail page — same name935<ViewTransition name={`product-${product.id}`}>936 <Image src={product.image} alt={product.name} width={800} height={600} />937</ViewTransition>938```939940## Same-Route Dynamic Segment Transitions941942Page stays mounted on dynamic segment change — enter/exit never fire. Use `key` + `name` + `share`:943944```tsx945<Suspense fallback={<Skeleton />}>946 <ViewTransition key={slug} name={`collection-${slug}`} share="auto" default="none">947 <Content slug={slug} />948 </ViewTransition>949</Suspense>950```951952## Server Components953954- `<ViewTransition>` works in Server and Client Components955- `<Link transitionTypes>` works in Server Components956- `addTransitionType` and programmatic nav require Client Components957<!-- forgecat:@forgecat/vercel-labs_agent-skills_react-view-transitions:react-view-transitions-agents:end -->958
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 |
|---|---|---|---|---|---|
| vllm-project/vllmAGENTS.md · 89k | AGENTS.md | setuptestlint-formatstyle+5 | 100/100 | 14 days ago | |
| code-yeongyu/oh-my-openagentpackages/web/AGENTS.md · 68k | AGENTS.md | setupbuildtestlint-format+6 | 100/100 | 13 days ago | |
| deepseek-ai/deepseek-harnessnative/landlock-run/AGENTS.md · 104k | AGENTS.md | setupteststylearch+3 | 100/100 | today | |
| TryGhost/Ghoste2e/AGENTS.md · 55k | AGENTS.md | setupteststylearch+2 | 100/100 | today | |
| 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 | |
| n8n-io/n8npackages/@n8n/agents/AGENTS.md · 201k | AGENTS.md | buildteststylearch+3 | 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-codex-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.